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 +10 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +25 -5
- package/dist/commands/gen.d.ts +2 -1
- package/dist/commands/gen.js +29 -7
- package/dist/compress.d.ts +13 -0
- package/dist/compress.js +97 -0
- package/dist/index.js +6 -1
- package/package.json +4 -1
- package/src/client.ts +33 -5
- package/src/commands/gen.ts +32 -8
- package/src/compress.ts +110 -0
- package/src/index.ts +7 -1
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/commands/gen.d.ts
CHANGED
package/dist/commands/gen.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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>;
|
package/dist/compress.js
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/gen.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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')
|
package/src/compress.ts
ADDED
|
@@ -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
|
-
|
|
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,自动上传)')
|