museav-cli 2.0.0 → 2.1.0

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
@@ -162,6 +162,16 @@ museav gen --prompt '...' --quality high
162
162
  # 图生图(自动上传垫图,保持人物面容)
163
163
  museav gen --prompt '保持面容,换成西装' --ref face.png
164
164
 
165
+ # 透明背景 PNG(抠掉背景,出带 alpha 通道的图;可与 --ref 叠加)
166
+ museav gen --prompt '一只橘猫,产品级抠图' --transparent
167
+ museav gen --prompt '把这只鞋抠成透明底' --ref shoe.jpg --transparent
168
+ # 注意三件事:
169
+ # · 只在提示词里写 "transparent background" 没用——那是构图描述,不是抠图开关,
170
+ # 真正生效的是 --transparent(它对应上游的 background 参数)
171
+ # · 仅部分上游支持。没有可用上游时中台直接报错,不会悄悄给你一张白底图
172
+ # (白底图看起来完全正常,静默降级只会让你以为提示词没写对,反复重试)
173
+ # · 会强制 PNG 输出:JPEG / 有损 WebP 没有 alpha 通道,装不下透明
174
+
165
175
  # 文生视频(模型如 seedance-2-fast / artsdance-2-0-pro-260801,自动轮询直到完成)
166
176
  museav gen --video --prompt '一只橘猫在窗台上伸懒腰,阳光洒进来,电影感' --model seedance-2-fast --ratio 9:16
167
177
 
package/dist/client.d.ts CHANGED
@@ -19,7 +19,18 @@ export interface GenerateOptions {
19
19
  ratio?: string;
20
20
  model?: string;
21
21
  reference_image?: string;
22
+ /** 多张参考图,顺序即提示词里的「图片1、图片2…」;中台按序喂给模型 */
23
+ reference_images?: string[];
22
24
  quality?: 'low' | 'medium' | 'high';
25
+ /**
26
+ * 出图背景。transparent = 抠掉背景出带 alpha 通道的 PNG;opaque = 明确要不透明背景;
27
+ * 不传 = 沿用上游默认(白底)。
28
+ *
29
+ * 跟上游 gpt-image 的参数同名同值,中台不做翻译。两个约束由中台强制、CLI 不重复实现:
30
+ * · 透明背景强制 PNG 输出(JPEG/有损 WebP 没有 alpha 通道)
31
+ * · 只派给声明了该能力的上游;一家都没有时返回 400 说明原因,**不会静默出白底图**
32
+ */
33
+ background?: 'transparent' | 'opaque';
23
34
  }
24
35
  /** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
25
36
  export interface TemplateOption {
@@ -347,6 +358,10 @@ export declare class StudioClient {
347
358
  * 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
348
359
  * 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
349
360
  */
361
+ /**
362
+ * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
363
+ * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
364
+ */
350
365
  uploadRef(filePath: string): Promise<{
351
366
  url: string;
352
367
  media_type?: string;
package/dist/client.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { basename } from 'node:path';
13
+ import { compressForVision } from './compress.js';
13
14
  /**
14
15
  * 客户端自报身份 —— 中台靠它把 gen_jobs.channel 记成 'cli',报错告警也靠它定位调用方。
15
16
  *
@@ -141,8 +142,13 @@ export class StudioClient {
141
142
  body.model = opts.model;
142
143
  if (opts.reference_image)
143
144
  body.reference_image = opts.reference_image;
145
+ // 单双字段一起发:中台优先取复数、为空才回落单数,两个都带着更稳
146
+ if (opts.reference_images?.length)
147
+ body.reference_images = opts.reference_images;
144
148
  if (opts.quality)
145
149
  body.quality = opts.quality;
150
+ if (opts.background)
151
+ body.background = opts.background;
146
152
  const r = await this.request('generate', {
147
153
  method: 'POST',
148
154
  headers: { 'Content-Type': 'application/json' },
@@ -240,7 +246,7 @@ export class StudioClient {
240
246
  */
241
247
  async reverse(input) {
242
248
  if (input.file) {
243
- return this.request('reverse', { method: 'POST', body: fileForm(input.file) });
249
+ return this.request('reverse', { method: 'POST', body: await fileForm(input.file) });
244
250
  }
245
251
  return this.request('reverse', {
246
252
  method: 'POST',
@@ -255,8 +261,12 @@ export class StudioClient {
255
261
  * 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
256
262
  * 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
257
263
  */
264
+ /**
265
+ * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
266
+ * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
267
+ */
258
268
  async uploadRef(filePath) {
259
- const r = await this.request('upload-ref', { method: 'POST', body: fileForm(filePath) });
269
+ const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) });
260
270
  return { url: r.url, media_type: r.media_type, mime: r.mime };
261
271
  }
262
272
  /**
@@ -274,7 +284,7 @@ export class StudioClient {
274
284
  if (file) {
275
285
  // multipart 分支:中台 formOptions() 对这几个键做 JSON.parse(variables 还支持逗号分隔),
276
286
  // 所以对象/数组要自己序列化成字符串,不能直接塞进 FormData。
277
- const fd = fileForm(file);
287
+ const fd = await fileForm(file);
278
288
  if (variables?.length)
279
289
  fd.append('variables', JSON.stringify(variables));
280
290
  if (variableLabels)
@@ -358,9 +368,19 @@ function sleep(ms) {
358
368
  * 带上原文件名:中台判类型靠字节魔数不靠这个,但文件名会进日志/对象存储的排查线索,
359
369
  * 匿名的 "blob" 出问题时谁也认不出是哪张图。故意不设 MIME——声明的 MIME 中台本来就不信。
360
370
  */
361
- function fileForm(filePath) {
371
+ /**
372
+ * 所有 multipart 上传的唯一入口,内置参考图压缩(见 compress.ts)。
373
+ * 压缩放这里而不是各调用点:uploadRef / reverse / image-to-template 都走它,
374
+ * 加在调用点就会漏——2026-08-16 就漏过 image-to-template,4.1MB 原图直传把任务拖挂了。
375
+ */
376
+ async function fileForm(filePath) {
377
+ const { buffer, filename, note } = await compressForVision(filePath);
378
+ if (note)
379
+ process.stderr.write(` ${note}\n`);
362
380
  const fd = new FormData();
363
- fd.append('file', new Blob([readFileSync(filePath)]), basename(filePath));
381
+ // Buffer → Uint8Array:Blob 的类型签名不收 Buffer(它可能背靠 SharedArrayBuffer)
382
+ const blob = buffer ? new Blob([new Uint8Array(buffer)]) : new Blob([new Uint8Array(readFileSync(filePath))]);
383
+ fd.append('file', blob, buffer ? filename : basename(filePath));
364
384
  return fd;
365
385
  }
366
386
  /**
@@ -9,7 +9,8 @@ export declare function gen(client: StudioClient, opts: {
9
9
  ratio?: string;
10
10
  model?: string;
11
11
  quality?: string;
12
- ref?: string;
12
+ ref?: string[];
13
+ transparent?: boolean;
13
14
  video?: boolean;
14
15
  duration?: number;
15
16
  image?: string;
@@ -1,3 +1,5 @@
1
+ /** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
2
+ const MAX_REFS = 5;
1
3
  export async function gen(client, opts) {
2
4
  // prompt / skill / template 三选一。commander 不好表达互斥,在这里校验,报错要说清怎么改
3
5
  const picked = [opts.prompt, opts.skill, opts.template].filter(Boolean).length;
@@ -16,6 +18,10 @@ export async function gen(client, opts) {
16
18
  if (opts.video && opts.skill) {
17
19
  throw new Error('--video 暂不支持配合 --skill(视频模板走 --template 或直接 --prompt)');
18
20
  }
21
+ // 视频没有 alpha 通道这回事(mp4 不带透明),本地就拦掉,别让用户等一趟往返才知道
22
+ if (opts.video && opts.transparent) {
23
+ throw new Error('--transparent 仅图片出图支持:视频输出是 mp4,没有 alpha 通道');
24
+ }
19
25
  let templateFields;
20
26
  if (opts.fields) {
21
27
  try {
@@ -25,14 +31,26 @@ export async function gen(client, opts) {
25
31
  throw new Error(`--fields 必须是合法 JSON 对象,如 '{"artist":"王嘉尔","city":"南京"}',收到: ${opts.fields}`);
26
32
  }
27
33
  }
28
- // 可选:先上传垫图(图片出图 --ref / 视频图生视频 --image 都走这里)
34
+ // 可选:先上传垫图(图片出图 --ref 可给多张 / 视频图生视频 --image 单张)
35
+ //
36
+ // 顺序有语义:中台把数组按序喂给模型,提示词里写「参考图片1的排版、用图片2当背景」
37
+ // 时,图片N 对应的就是这里的第 N 个 --ref。所以上传要顺序执行、不能并发抢跑。
29
38
  let referenceImage;
30
- const refPath = opts.ref || opts.image;
31
- if (refPath) {
32
- process.stderr.write(`上传垫图 ${refPath} ...\n`);
33
- const up = await client.uploadRef(refPath);
34
- referenceImage = up.url;
35
- process.stderr.write(`垫图就绪: ${referenceImage}\n`);
39
+ let referenceImages;
40
+ const refPaths = [...(opts.ref || []), ...(opts.image ? [opts.image] : [])];
41
+ if (refPaths.length > MAX_REFS) {
42
+ throw new Error(`参考图最多 ${MAX_REFS} 张,收到 ${refPaths.length} 张`);
43
+ }
44
+ if (refPaths.length) {
45
+ const urls = [];
46
+ for (const [i, refPath] of refPaths.entries()) {
47
+ process.stderr.write(`上传垫图 [图片${i + 1}] ${refPath} ...\n`);
48
+ const up = await client.uploadRef(refPath);
49
+ urls.push(up.url);
50
+ process.stderr.write(` 图片${i + 1} 就绪: ${up.url}\n`);
51
+ }
52
+ referenceImage = urls[0]; // 兼容:中台单数字段仍收
53
+ referenceImages = urls.length > 1 ? urls : undefined;
36
54
  }
37
55
  // ── 视频模式:走 /api/videos 独立链路 ──
38
56
  if (opts.video) {
@@ -78,6 +96,10 @@ export async function gen(client, opts) {
78
96
  model: opts.model,
79
97
  quality: opts.quality,
80
98
  reference_image: referenceImage,
99
+ reference_images: referenceImages,
100
+ // 开关 → 枚举:CLI 这层用布尔开关最顺手,中台契约是 background: transparent|opaque
101
+ // (跟上游 gpt-image 的参数同名同值)。不传就不发,行为跟以前完全一样。
102
+ background: opts.transparent ? 'transparent' : undefined,
81
103
  }, (status) => {
82
104
  if (status === 'processing')
83
105
  process.stderr.write('生成中...\r');
@@ -0,0 +1,13 @@
1
+ export interface CompressResult {
2
+ /** 要上传的数据;未压缩时为 null,表示用原文件 */
3
+ buffer: Buffer | null;
4
+ /** 上传时用的文件名(转了格式要换扩展名) */
5
+ filename: string;
6
+ /** 给人看的一行说明,未压缩时为空 */
7
+ note: string;
8
+ }
9
+ /**
10
+ * @param filePath 本地图片路径
11
+ * @returns 压缩结果;非图片、体积已达标、或 sharp 不可用时 buffer 为 null
12
+ */
13
+ export declare function compressForVision(filePath: string): Promise<CompressResult>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 参考图压缩 —— 上传前把图缩到视觉模型够用的尺寸。
3
+ *
4
+ * 参考图的用途是「让模型看懂画面」,不是留档,不需要原始分辨率。实测 4.1MB 的海报
5
+ * 直接传上去,中台那次图生模板任务卡在「解析图片」再没回来(后台任务被 Cloudflare
6
+ * 掐掉,任务永远 pending)。50MB 的图更不用说。
7
+ *
8
+ * sharp 是 optionalDependency:原生模块在个别平台会装不上,装不上也不能让整个 CLI
9
+ * 用不了。取不到就原样上传并提示——压缩是优化,不是前置条件。
10
+ */
11
+ import { stat } from 'node:fs/promises';
12
+ import { basename } from 'node:path';
13
+ /** 长边上限:主流视觉模型的有效输入分辨率都在 1.5k 上下,再大只是浪费带宽和解析时间 */
14
+ const MAX_EDGE = 1568;
15
+ /** 小于这个体积且尺寸不超标就原样传,不折腾 */
16
+ const SKIP_BELOW_BYTES = 900 * 1024;
17
+ /** 压完仍超过它就再降一档质量 */
18
+ const TARGET_BYTES = 1.5 * 1024 * 1024;
19
+ async function loadSharp() {
20
+ try {
21
+ const m = await import('sharp');
22
+ return m.default ?? m;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * @param filePath 本地图片路径
30
+ * @returns 压缩结果;非图片、体积已达标、或 sharp 不可用时 buffer 为 null
31
+ */
32
+ export async function compressForVision(filePath) {
33
+ const name = basename(filePath);
34
+ const orig = (await stat(filePath)).size;
35
+ const sharp = await loadSharp();
36
+ if (!sharp) {
37
+ return {
38
+ buffer: null,
39
+ filename: name,
40
+ note: orig > SKIP_BELOW_BYTES
41
+ ? `未安装 sharp,${(orig / 1024 / 1024).toFixed(1)}MB 原图直传(大图可能导致解析超时)`
42
+ : '',
43
+ };
44
+ }
45
+ let meta;
46
+ try {
47
+ meta = await sharp(filePath).metadata();
48
+ }
49
+ catch {
50
+ return { buffer: null, filename: name, note: '' }; // 不是 sharp 认识的图(视频/音频)→ 原样传
51
+ }
52
+ const longEdge = Math.max(meta.width || 0, meta.height || 0);
53
+ if (orig <= SKIP_BELOW_BYTES && longEdge <= MAX_EDGE) {
54
+ return { buffer: null, filename: name, note: '' };
55
+ }
56
+ // 有 alpha 的保持 PNG(贴图类素材的透明通道不能丢),其余一律转 JPEG——
57
+ // 同样画质下 JPEG 比 PNG 小一个数量级,而参考图不需要无损。
58
+ //
59
+ // 只看 meta.hasAlpha 不够:截图工具产出的 PNG 普遍带一条**全不透明**的 alpha 通道,
60
+ // 照着它走 PNG 分支等于白白多存几倍体积(实测 4.1MB 海报按 PNG 只压到 1.16MB,
61
+ // 按 JPEG 是 0.2MB)。用 stats().isOpaque 判断透明通道有没有被真正用到。
62
+ let hasAlpha = !!meta.hasAlpha;
63
+ if (hasAlpha) {
64
+ try {
65
+ const st = await sharp(filePath).stats();
66
+ if (st.isOpaque)
67
+ hasAlpha = false;
68
+ }
69
+ catch { /* 统计失败就按有 alpha 保守处理 */ }
70
+ }
71
+ const pipeline = sharp(filePath).rotate() // rotate() 不带参数=按 EXIF 摆正,否则手机竖拍图会躺着
72
+ .resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true });
73
+ let out;
74
+ let ext;
75
+ if (hasAlpha) {
76
+ out = await pipeline.png({ compressionLevel: 9, palette: true }).toBuffer();
77
+ ext = 'png';
78
+ }
79
+ else {
80
+ out = await pipeline.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
81
+ ext = 'jpg';
82
+ if (out.byteLength > TARGET_BYTES) {
83
+ out = await sharp(filePath).rotate()
84
+ .resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
85
+ .jpeg({ quality: 68, mozjpeg: true }).toBuffer();
86
+ }
87
+ }
88
+ // 压完反而更大(本来就是小图/高压缩率的 WebP 之类)就别换了
89
+ if (out.byteLength >= orig)
90
+ return { buffer: null, filename: name, note: '' };
91
+ const pct = Math.round((1 - out.byteLength / orig) * 100);
92
+ return {
93
+ buffer: out,
94
+ filename: name.replace(/\.[^.]+$/, '') + '.' + ext,
95
+ note: `已压缩 ${(orig / 1024 / 1024).toFixed(1)}MB → ${(out.byteLength / 1024 / 1024).toFixed(2)}MB(-${pct}%,长边 ≤ ${MAX_EDGE}px)`,
96
+ };
97
+ }
package/dist/index.js CHANGED
@@ -127,7 +127,12 @@ program
127
127
  .option('-r, --ratio <ratio>', '宽高比: 3:4 / 9:16 / 1:1 / 4:3 / 16:9(不指定则用技能/模板自己的比例,纯 prompt 模式兜底 3:4)')
128
128
  .option('-m, --model <name>', '指定模型,如 gpt-image-2 / seedance-2-fast / artsdance-2-0-pro-260801')
129
129
  .option('-q, --quality <level>', '质量: low / medium / high(仅 gpt-image)')
130
- .option('--ref <file>', '垫图文件路径(图片图生图,自动上传)')
130
+ // 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
131
+ // 用图片2作为背景」时,图片N 对应第 N 个 --ref。commander 的 collect 保证顺序。
132
+ .option('--ref <file>', '垫图文件路径,可重复传多张(最多 5 张,顺序对应提示词里的「图片1、图片2…」)', (v, acc) => [...acc, v], [])
133
+ // 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
134
+ // "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
135
+ .option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
131
136
  .option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro)')
132
137
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
133
138
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,5 +51,8 @@
51
51
  },
52
52
  "publishConfig": {
53
53
  "access": "public"
54
+ },
55
+ "optionalDependencies": {
56
+ "sharp": "^0.35.3"
54
57
  }
55
58
  }
package/src/client.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { readFileSync } from 'node:fs'
12
12
  import { basename } from 'node:path'
13
+ import { compressForVision } from './compress.js'
13
14
 
14
15
  /**
15
16
  * 客户端自报身份 —— 中台靠它把 gen_jobs.channel 记成 'cli',报错告警也靠它定位调用方。
@@ -67,7 +68,18 @@ export interface GenerateOptions {
67
68
  ratio?: string
68
69
  model?: string
69
70
  reference_image?: string
71
+ /** 多张参考图,顺序即提示词里的「图片1、图片2…」;中台按序喂给模型 */
72
+ reference_images?: string[]
70
73
  quality?: 'low' | 'medium' | 'high'
74
+ /**
75
+ * 出图背景。transparent = 抠掉背景出带 alpha 通道的 PNG;opaque = 明确要不透明背景;
76
+ * 不传 = 沿用上游默认(白底)。
77
+ *
78
+ * 跟上游 gpt-image 的参数同名同值,中台不做翻译。两个约束由中台强制、CLI 不重复实现:
79
+ * · 透明背景强制 PNG 输出(JPEG/有损 WebP 没有 alpha 通道)
80
+ * · 只派给声明了该能力的上游;一家都没有时返回 400 说明原因,**不会静默出白底图**
81
+ */
82
+ background?: 'transparent' | 'opaque'
71
83
  }
72
84
 
73
85
  /** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
@@ -389,7 +401,10 @@ export class StudioClient {
389
401
  if (opts.ratio) body.ratio = opts.ratio
390
402
  if (opts.model) body.model = opts.model
391
403
  if (opts.reference_image) body.reference_image = opts.reference_image
404
+ // 单双字段一起发:中台优先取复数、为空才回落单数,两个都带着更稳
405
+ if (opts.reference_images?.length) body.reference_images = opts.reference_images
392
406
  if (opts.quality) body.quality = opts.quality
407
+ if (opts.background) body.background = opts.background
393
408
  const r = await this.request('generate', {
394
409
  method: 'POST',
395
410
  headers: { 'Content-Type': 'application/json' },
@@ -500,7 +515,7 @@ export class StudioClient {
500
515
  */
501
516
  async reverse(input: { file?: string; imageUrl?: string }): Promise<ReverseResult> {
502
517
  if (input.file) {
503
- return this.request('reverse', { method: 'POST', body: fileForm(input.file) })
518
+ return this.request('reverse', { method: 'POST', body: await fileForm(input.file) })
504
519
  }
505
520
  return this.request('reverse', {
506
521
  method: 'POST',
@@ -516,8 +531,12 @@ export class StudioClient {
516
531
  * 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
517
532
  * 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
518
533
  */
534
+ /**
535
+ * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
536
+ * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
537
+ */
519
538
  async uploadRef(filePath: string): Promise<{ url: string; media_type?: string; mime?: string }> {
520
- const r = await this.request('upload-ref', { method: 'POST', body: fileForm(filePath) })
539
+ const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) })
521
540
  return { url: r.url, media_type: r.media_type, mime: r.mime }
522
541
  }
523
542
 
@@ -536,7 +555,7 @@ export class StudioClient {
536
555
  if (file) {
537
556
  // multipart 分支:中台 formOptions() 对这几个键做 JSON.parse(variables 还支持逗号分隔),
538
557
  // 所以对象/数组要自己序列化成字符串,不能直接塞进 FormData。
539
- const fd = fileForm(file)
558
+ const fd = await fileForm(file)
540
559
  if (variables?.length) fd.append('variables', JSON.stringify(variables))
541
560
  if (variableLabels) fd.append('variable_labels', JSON.stringify(variableLabels))
542
561
  if (createTemplate) fd.append('create_template', 'true')
@@ -621,9 +640,18 @@ function sleep(ms: number): Promise<void> {
621
640
  * 带上原文件名:中台判类型靠字节魔数不靠这个,但文件名会进日志/对象存储的排查线索,
622
641
  * 匿名的 "blob" 出问题时谁也认不出是哪张图。故意不设 MIME——声明的 MIME 中台本来就不信。
623
642
  */
624
- function fileForm(filePath: string): FormData {
643
+ /**
644
+ * 所有 multipart 上传的唯一入口,内置参考图压缩(见 compress.ts)。
645
+ * 压缩放这里而不是各调用点:uploadRef / reverse / image-to-template 都走它,
646
+ * 加在调用点就会漏——2026-08-16 就漏过 image-to-template,4.1MB 原图直传把任务拖挂了。
647
+ */
648
+ async function fileForm(filePath: string): Promise<FormData> {
649
+ const { buffer, filename, note } = await compressForVision(filePath)
650
+ if (note) process.stderr.write(` ${note}\n`)
625
651
  const fd = new FormData()
626
- fd.append('file', new Blob([readFileSync(filePath)]), basename(filePath))
652
+ // Buffer → Uint8Array:Blob 的类型签名不收 Buffer(它可能背靠 SharedArrayBuffer)
653
+ const blob = buffer ? new Blob([new Uint8Array(buffer)]) : new Blob([new Uint8Array(readFileSync(filePath))])
654
+ fd.append('file', blob, buffer ? filename : basename(filePath))
627
655
  return fd
628
656
  }
629
657
 
@@ -1,6 +1,9 @@
1
1
  /** museav gen —— 出图 / 出视频(核心命令) */
2
2
  import type { StudioClient } from '../client.js'
3
3
 
4
+ /** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
5
+ const MAX_REFS = 5
6
+
4
7
  export async function gen(client: StudioClient, opts: {
5
8
  prompt?: string
6
9
  skill?: string
@@ -10,7 +13,8 @@ export async function gen(client: StudioClient, opts: {
10
13
  ratio?: string
11
14
  model?: string
12
15
  quality?: string
13
- ref?: string
16
+ ref?: string[] // 可重复:--ref a.jpg --ref b.jpg,顺序即「图片1、图片2…」
17
+ transparent?: boolean // 透明背景 PNG;能不能做由中台按上游能力判定,做不了会明确报错
14
18
  // 视频
15
19
  video?: boolean
16
20
  duration?: number
@@ -33,6 +37,10 @@ export async function gen(client: StudioClient, opts: {
33
37
  if (opts.video && opts.skill) {
34
38
  throw new Error('--video 暂不支持配合 --skill(视频模板走 --template 或直接 --prompt)')
35
39
  }
40
+ // 视频没有 alpha 通道这回事(mp4 不带透明),本地就拦掉,别让用户等一趟往返才知道
41
+ if (opts.video && opts.transparent) {
42
+ throw new Error('--transparent 仅图片出图支持:视频输出是 mp4,没有 alpha 通道')
43
+ }
36
44
  let templateFields: Record<string, string> | undefined
37
45
  if (opts.fields) {
38
46
  try {
@@ -42,14 +50,26 @@ export async function gen(client: StudioClient, opts: {
42
50
  }
43
51
  }
44
52
 
45
- // 可选:先上传垫图(图片出图 --ref / 视频图生视频 --image 都走这里)
53
+ // 可选:先上传垫图(图片出图 --ref 可给多张 / 视频图生视频 --image 单张)
54
+ //
55
+ // 顺序有语义:中台把数组按序喂给模型,提示词里写「参考图片1的排版、用图片2当背景」
56
+ // 时,图片N 对应的就是这里的第 N 个 --ref。所以上传要顺序执行、不能并发抢跑。
46
57
  let referenceImage: string | undefined
47
- const refPath = opts.ref || opts.image
48
- if (refPath) {
49
- process.stderr.write(`上传垫图 ${refPath} ...\n`)
50
- const up = await client.uploadRef(refPath)
51
- referenceImage = up.url
52
- process.stderr.write(`垫图就绪: ${referenceImage}\n`)
58
+ let referenceImages: string[] | undefined
59
+ const refPaths = [...(opts.ref || []), ...(opts.image ? [opts.image] : [])]
60
+ if (refPaths.length > MAX_REFS) {
61
+ throw new Error(`参考图最多 ${MAX_REFS} 张,收到 ${refPaths.length} 张`)
62
+ }
63
+ if (refPaths.length) {
64
+ const urls: string[] = []
65
+ for (const [i, refPath] of refPaths.entries()) {
66
+ process.stderr.write(`上传垫图 [图片${i + 1}] ${refPath} ...\n`)
67
+ const up = await client.uploadRef(refPath)
68
+ urls.push(up.url)
69
+ process.stderr.write(` 图片${i + 1} 就绪: ${up.url}\n`)
70
+ }
71
+ referenceImage = urls[0] // 兼容:中台单数字段仍收
72
+ referenceImages = urls.length > 1 ? urls : undefined
53
73
  }
54
74
 
55
75
  // ── 视频模式:走 /api/videos 独立链路 ──
@@ -99,6 +119,10 @@ export async function gen(client: StudioClient, opts: {
99
119
  model: opts.model,
100
120
  quality: opts.quality as 'low' | 'medium' | 'high' | undefined,
101
121
  reference_image: referenceImage,
122
+ reference_images: referenceImages,
123
+ // 开关 → 枚举:CLI 这层用布尔开关最顺手,中台契约是 background: transparent|opaque
124
+ // (跟上游 gpt-image 的参数同名同值)。不传就不发,行为跟以前完全一样。
125
+ background: opts.transparent ? 'transparent' : undefined,
102
126
  },
103
127
  (status) => {
104
128
  if (status === 'processing') process.stderr.write('生成中...\r')
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 参考图压缩 —— 上传前把图缩到视觉模型够用的尺寸。
3
+ *
4
+ * 参考图的用途是「让模型看懂画面」,不是留档,不需要原始分辨率。实测 4.1MB 的海报
5
+ * 直接传上去,中台那次图生模板任务卡在「解析图片」再没回来(后台任务被 Cloudflare
6
+ * 掐掉,任务永远 pending)。50MB 的图更不用说。
7
+ *
8
+ * sharp 是 optionalDependency:原生模块在个别平台会装不上,装不上也不能让整个 CLI
9
+ * 用不了。取不到就原样上传并提示——压缩是优化,不是前置条件。
10
+ */
11
+ import { stat } from 'node:fs/promises'
12
+ import { basename } from 'node:path'
13
+
14
+ /** 长边上限:主流视觉模型的有效输入分辨率都在 1.5k 上下,再大只是浪费带宽和解析时间 */
15
+ const MAX_EDGE = 1568
16
+ /** 小于这个体积且尺寸不超标就原样传,不折腾 */
17
+ const SKIP_BELOW_BYTES = 900 * 1024
18
+ /** 压完仍超过它就再降一档质量 */
19
+ const TARGET_BYTES = 1.5 * 1024 * 1024
20
+
21
+ export interface CompressResult {
22
+ /** 要上传的数据;未压缩时为 null,表示用原文件 */
23
+ buffer: Buffer | null
24
+ /** 上传时用的文件名(转了格式要换扩展名) */
25
+ filename: string
26
+ /** 给人看的一行说明,未压缩时为空 */
27
+ note: string
28
+ }
29
+
30
+ async function loadSharp(): Promise<any | null> {
31
+ try {
32
+ const m = await import('sharp')
33
+ return (m as any).default ?? m
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param filePath 本地图片路径
41
+ * @returns 压缩结果;非图片、体积已达标、或 sharp 不可用时 buffer 为 null
42
+ */
43
+ export async function compressForVision(filePath: string): Promise<CompressResult> {
44
+ const name = basename(filePath)
45
+ const orig = (await stat(filePath)).size
46
+
47
+ const sharp = await loadSharp()
48
+ if (!sharp) {
49
+ return {
50
+ buffer: null,
51
+ filename: name,
52
+ note: orig > SKIP_BELOW_BYTES
53
+ ? `未安装 sharp,${(orig / 1024 / 1024).toFixed(1)}MB 原图直传(大图可能导致解析超时)`
54
+ : '',
55
+ }
56
+ }
57
+
58
+ let meta
59
+ try {
60
+ meta = await sharp(filePath).metadata()
61
+ } catch {
62
+ return { buffer: null, filename: name, note: '' } // 不是 sharp 认识的图(视频/音频)→ 原样传
63
+ }
64
+
65
+ const longEdge = Math.max(meta.width || 0, meta.height || 0)
66
+ if (orig <= SKIP_BELOW_BYTES && longEdge <= MAX_EDGE) {
67
+ return { buffer: null, filename: name, note: '' }
68
+ }
69
+
70
+ // 有 alpha 的保持 PNG(贴图类素材的透明通道不能丢),其余一律转 JPEG——
71
+ // 同样画质下 JPEG 比 PNG 小一个数量级,而参考图不需要无损。
72
+ //
73
+ // 只看 meta.hasAlpha 不够:截图工具产出的 PNG 普遍带一条**全不透明**的 alpha 通道,
74
+ // 照着它走 PNG 分支等于白白多存几倍体积(实测 4.1MB 海报按 PNG 只压到 1.16MB,
75
+ // 按 JPEG 是 0.2MB)。用 stats().isOpaque 判断透明通道有没有被真正用到。
76
+ let hasAlpha = !!meta.hasAlpha
77
+ if (hasAlpha) {
78
+ try {
79
+ const st = await sharp(filePath).stats()
80
+ if (st.isOpaque) hasAlpha = false
81
+ } catch { /* 统计失败就按有 alpha 保守处理 */ }
82
+ }
83
+ const pipeline = sharp(filePath).rotate() // rotate() 不带参数=按 EXIF 摆正,否则手机竖拍图会躺着
84
+ .resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
85
+
86
+ let out: Buffer
87
+ let ext: string
88
+ if (hasAlpha) {
89
+ out = await pipeline.png({ compressionLevel: 9, palette: true }).toBuffer()
90
+ ext = 'png'
91
+ } else {
92
+ out = await pipeline.jpeg({ quality: 82, mozjpeg: true }).toBuffer()
93
+ ext = 'jpg'
94
+ if (out.byteLength > TARGET_BYTES) {
95
+ out = await sharp(filePath).rotate()
96
+ .resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
97
+ .jpeg({ quality: 68, mozjpeg: true }).toBuffer()
98
+ }
99
+ }
100
+
101
+ // 压完反而更大(本来就是小图/高压缩率的 WebP 之类)就别换了
102
+ if (out.byteLength >= orig) return { buffer: null, filename: name, note: '' }
103
+
104
+ const pct = Math.round((1 - out.byteLength / orig) * 100)
105
+ return {
106
+ buffer: out,
107
+ filename: name.replace(/\.[^.]+$/, '') + '.' + ext,
108
+ note: `已压缩 ${(orig / 1024 / 1024).toFixed(1)}MB → ${(out.byteLength / 1024 / 1024).toFixed(2)}MB(-${pct}%,长边 ≤ ${MAX_EDGE}px)`,
109
+ }
110
+ }
package/src/index.ts CHANGED
@@ -129,7 +129,13 @@ program
129
129
  .option('-r, --ratio <ratio>', '宽高比: 3:4 / 9:16 / 1:1 / 4:3 / 16:9(不指定则用技能/模板自己的比例,纯 prompt 模式兜底 3:4)')
130
130
  .option('-m, --model <name>', '指定模型,如 gpt-image-2 / seedance-2-fast / artsdance-2-0-pro-260801')
131
131
  .option('-q, --quality <level>', '质量: low / medium / high(仅 gpt-image)')
132
- .option('--ref <file>', '垫图文件路径(图片图生图,自动上传)')
132
+ // 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
133
+ // 用图片2作为背景」时,图片N 对应第 N 个 --ref。commander 的 collect 保证顺序。
134
+ .option('--ref <file>', '垫图文件路径,可重复传多张(最多 5 张,顺序对应提示词里的「图片1、图片2…」)',
135
+ (v: string, acc: string[]) => [...acc, v], [] as string[])
136
+ // 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
137
+ // "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
138
+ .option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
133
139
  .option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro)')
134
140
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
135
141
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')