museav-cli 2.0.0 → 2.2.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/AGENTS.md CHANGED
@@ -19,8 +19,10 @@ Don't try both — whichever credential is present is what gets used (env var `S
19
19
  # Generate an image, wait for it, get the URL on stdout
20
20
  museav gen --prompt 'a poster, neon lights, cyberpunk' --ratio 9:16
21
21
 
22
- # Generate a video (文生视频/图生视频), wait, get the mp4 URL on stdout
23
- museav gen --video --prompt 'a cat stretching on a windowsill, cinematic' --model seedance-2-fast --ratio 9:16
22
+ # Generate a video (文生视频/图生视频), wait, get the mp4 URL on stdout.
23
+ # Omit --model to let the platform route automatically (auto); pinning a tier uses
24
+ # artsdance-* ids like artsdance-2-0-pro-260801 (Seedance 2.0).
25
+ museav gen --video --prompt 'a cat stretching on a windowsill, cinematic' --ratio 9:16
24
26
  museav gen --video --image logo.png --prompt 'logo glows slowly, background fades' --ratio 1:1
25
27
 
26
28
  # Generate from a pre-configured image template instead of a raw prompt (deterministic
@@ -37,6 +39,10 @@ museav gen --template <id> --fields '{"artist":"name","city":"place"}'
37
39
  museav templates create --name '演唱会海报' --prompt '{artist} 在 {city} 的演唱会海报' --ratio 9:16
38
40
 
39
41
  # Reverse-engineer a prompt from an existing image (stdout: English prompt only).
42
+ # PRIMARY path is LOCAL: Ollama qwen3-vl — fast, free, NO login needed. Falls back to
43
+ # the platform API (with a slowness warning) only if Ollama isn't running or the model
44
+ # is missing. Image URLs always go to the API (local path takes file paths only);
45
+ # --api forces the API path.
40
46
  # This READS the image and nothing else — it will NOT build a template. Passing any
41
47
  # template-ish flag to the underlying API is a hard 400 since 2026-08-16.
42
48
  museav reverse ./photo.png
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 · 2026-08-17
4
+
5
+ **`reverse` 主路改本地:Ollama + qwen3-vl。** 中台 API 读图要上传、排队、等云端推理;本地 8b 量化模型在自己机器上跑,免登录、零成本、离线可用。本地不可用(服务没起 / 模型没拉 / 推理出错)自动回落中台 API,回落时明确提示较慢,缺什么会告诉你补什么命令。
6
+
7
+ - 一次性配置:`brew install ollama && brew services start ollama && ollama pull qwen3-vl:8b`(Intel Mac / Linux 用官方安装脚本同理)。换模型档位设 `MUSEAV_LOCAL_VLM`(如 `qwen3-vl:4b`,更快、识图质量略降),自定服务地址走标准 `OLLAMA_HOST`。
8
+ - SCULPT 提示词从中台移植,砍掉本地不消费的 genre / body_md;返回归一化后与 API 路完全同构,`$(museav reverse x.png)` 管线用法不变,stdout 仍只出英文 prompt。
9
+ - 读图前复用上传同款压缩(压到视觉模型够用的尺寸),本地推理也跟着快。
10
+ - URL 输入固定走中台 API(本地路只收文件路径);`--api` 强制走中台。
11
+ - 本地路完全不需要中台凭证——未登录也能用,只有真回落 API 时才提示登录。
12
+
13
+ **修复 `video-templates create` 必被 400 拒**:中台视频模板契约硬性要求 `slug`(required=['zh_name','slug']),且 toapis 2026-08-14 下线后 CLI 默认的 `seedance-2` 成了悬空引用——不带参数建视频模板必挂。现在 `--slug` 缺省自动生成 `vt-` 短标识并在 stderr 回显,默认模型改 `auto`(交给中台路由),`--duration` 放宽到 4-30(Seedance 2.5 支持到 30 秒)。
14
+
15
+ - `templates` / `video-templates` 列表兼容读 config 顶层的 `fields`(现行契约存法——CLI 自己 create 写的就是顶层,之前自己建的模板自己都列不出字段)。
16
+ - `gen` / `video-templates` 帮助文案与 README / AGENTS.md 示例里已下线的 `seedance-2-*` 代号全部清掉,照旧文案传参会 400。
17
+
3
18
  ## 2.0.0 · 2026-08-16
4
19
 
5
20
  **改名:npm 包 `museav-cli`,命令 `museav`。** 产品叫 MUSE AV,命令却叫另一个名字,同一个东西两个叫法——这次统一到产品名。
package/README.md CHANGED
@@ -162,8 +162,18 @@ museav gen --prompt '...' --quality high
162
162
  # 图生图(自动上传垫图,保持人物面容)
163
163
  museav gen --prompt '保持面容,换成西装' --ref face.png
164
164
 
165
- # 文生视频(模型如 seedance-2-fast / artsdance-2-0-pro-260801,自动轮询直到完成)
166
- museav gen --video --prompt '一只橘猫在窗台上伸懒腰,阳光洒进来,电影感' --model seedance-2-fast --ratio 9:16
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
+
175
+ # 文生视频(不传 --model 走 auto 路由;锁死档次用 artsdance-2-0-pro-260801 这类代号,自动轮询直到完成)
176
+ museav gen --video --prompt '一只橘猫在窗台上伸懒腰,阳光洒进来,电影感' --ratio 9:16
167
177
 
168
178
  # 图生视频(--image 传首帧图,自动上传)
169
179
  museav gen --video --image logo.png --prompt 'logo 缓缓发光,背景渐暗' --ratio 1:1
@@ -227,17 +237,24 @@ museav gen --template "$ID" --fields '{"artist":"..."}'
227
237
 
228
238
  ### 图片逆向 `reverse`
229
239
 
230
- 上传一张图,中台用 **SCULPT 六要素**(主体/构图/世界观/光影/输出/质感)逆推出图 prompt,可以直接拿去再出一张同风格的:
240
+ 逆推出图 prompt,可以直接拿去再出一张同风格的。**主路是本地 Ollama(qwen3-vl)**:快、零成本、无需登录;本地不可用时自动回落中台 API(SCULPT 六要素:主体/构图/世界观/光影/输出/质感),回落时会明确提示较慢:
231
241
 
232
242
  ```bash
233
- # 本地文件或图片 URL 都行
243
+ # 本地文件走本地模型(推荐,无需登录)
234
244
  museav reverse photo.png
245
+
246
+ # 图片 URL 没法喂本地模型,走中台 API(需登录)
235
247
  museav reverse https://example.com/photo.png
236
248
 
249
+ # 强制走中台 API(跳过本地,慢,需登录)
250
+ museav reverse photo.png --api
251
+
237
252
  # 逆向 + 出图,一条龙
238
253
  museav gen --prompt "$(museav reverse photo.png)"
239
254
  ```
240
255
 
256
+ 本地路依赖:Ollama 运行中(`brew services start ollama`)+ 模型在位(`ollama pull qwen3-vl:8b`,换档位设 `MUSEAV_LOCAL_VLM`,自定地址设 `OLLAMA_HOST`)。缺哪个都会提示对应命令并回落 API。
257
+
241
258
  分析详情打到 stderr(人看),**stdout 只输出英文 prompt**(机器用,方便管道)。
242
259
 
243
260
  `reverse` **只读图**,不会顺手帮你建模板。要把图做成模板看下一节——中台 2026-08-16 把这两件事
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 {
@@ -40,6 +51,12 @@ export interface TemplateOption {
40
51
  model: string;
41
52
  prompt_template: string;
42
53
  ref_slots?: string[];
54
+ /** 表单字段声明——现行契约放 config 顶层(服务端 validateConfig 读这里) */
55
+ fields?: Array<{
56
+ key: string;
57
+ label: string;
58
+ }>;
59
+ /** 旧存法:fields 曾在 params_json 里,老模板还这么存,读时两种都要兜 */
43
60
  params_json?: {
44
61
  fields?: Array<{
45
62
  key: string;
@@ -47,6 +64,8 @@ export interface TemplateOption {
47
64
  placeholder?: string;
48
65
  }>;
49
66
  };
67
+ duration?: number;
68
+ aspect_ratio?: string;
50
69
  is_default?: boolean;
51
70
  }>;
52
71
  }
@@ -78,6 +97,8 @@ export interface CreateTemplateInput {
78
97
  * ratio/duration/model 放在 generation_configs 每项里。 */
79
98
  export interface CreateVideoTemplateInput {
80
99
  zh_name: string;
100
+ /** 对外调用标识,视频模板硬必填(服务端 validateCore required=['zh_name','slug']),全局唯一 */
101
+ slug: string;
81
102
  category?: string;
82
103
  description?: string;
83
104
  sample_video_url?: string | null;
@@ -347,6 +368,10 @@ export declare class StudioClient {
347
368
  * 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
348
369
  * 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
349
370
  */
371
+ /**
372
+ * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
373
+ * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
374
+ */
350
375
  uploadRef(filePath: string): Promise<{
351
376
  url: string;
352
377
  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');
@@ -1,3 +1,8 @@
1
- /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt) */
1
+ /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
2
+ * 主路是本地 Ollama(qwen3-vl),快、零成本、无需登录;中台 API 是回落路,走回落时会明确提示较慢。
3
+ * client 懒构造(getClient):本地路成功就完全不碰中台凭证。
4
+ * 本地系统的 AI 能力统一收口在这个 CLI,reverse 是第一个本地化的能力。 */
2
5
  import type { StudioClient } from '../client.js';
3
- export declare function reverse(client: StudioClient, input: string): Promise<void>;
6
+ export declare function reverse(getClient: () => StudioClient, input: string, opts?: {
7
+ api?: boolean;
8
+ }): Promise<void>;
@@ -1,7 +1,33 @@
1
- export async function reverse(client, input) {
2
- // 输入是文件路径还是 URL
1
+ import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js';
2
+ export async function reverse(getClient, input, opts = {}) {
3
3
  const isUrl = /^https?:\/\//.test(input);
4
+ if (!opts.api && !isUrl) {
5
+ const status = await checkLocalVlm();
6
+ if (status.running && status.modelPresent) {
7
+ try {
8
+ const start = Date.now();
9
+ const result = await reverseLocally(input);
10
+ process.stderr.write(`✓ 本地 Ollama(${LOCAL_VLM_MODEL})用时 ${((Date.now() - start) / 1000).toFixed(1)}s\n`);
11
+ renderReverse(result);
12
+ return;
13
+ }
14
+ catch (e) {
15
+ process.stderr.write(`⚠ 本地读图失败(${e instanceof Error ? e.message : e}),回落中台 API —— 速度较慢,请耐心等待\n`);
16
+ }
17
+ }
18
+ else {
19
+ process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`);
20
+ }
21
+ }
22
+ else if (!opts.api && isUrl) {
23
+ process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`);
24
+ }
25
+ const client = getClient();
4
26
  const result = await client.reverse(isUrl ? { imageUrl: input } : { file: input });
27
+ renderReverse(result);
28
+ }
29
+ /** 两条路产出同构,渲染只写一份 */
30
+ function renderReverse(result) {
5
31
  process.stderr.write(`✅ 逆向完成\n\n`);
6
32
  process.stderr.write(`风格: ${result.zh_name || '-'} 比例: ${result.aspect_ratio}\n`);
7
33
  process.stderr.write(`标签: ${result.style_tags.join(', ')}\n\n`);
@@ -13,7 +13,8 @@ export async function templates(client, opts = {}) {
13
13
  process.stderr.write(`可用模板(${list.length} 个):\n`);
14
14
  for (const t of list) {
15
15
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0];
16
- const fields = cfg?.params_json?.fields || [];
16
+ // fields 新契约在 config 顶层(CLI 自己 create 就写顶层),老数据在 params_json 里——两种都兜
17
+ const fields = cfg?.fields || cfg?.params_json?.fields || [];
17
18
  const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : '';
18
19
  process.stderr.write(` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(16)} ${(t.category || '').padEnd(10)} ${(t.ratio || '').padEnd(6)} ${typeTag(t).padEnd(8)} ${fieldHint.padEnd(20)} ${tag(t)}\n`);
19
20
  }
@@ -5,6 +5,8 @@ export declare function videoTemplates(client: StudioClient, opts?: {
5
5
  }): Promise<void>;
6
6
  interface CreateVideoTemplateOpts {
7
7
  name: string;
8
+ /** 对外调用标识,全局唯一。不给就自动生成一个(vt- 前缀) */
9
+ slug?: string;
8
10
  prompt: string;
9
11
  category?: string;
10
12
  description?: string;
@@ -14,8 +14,9 @@ export async function videoTemplates(client, opts = {}) {
14
14
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0];
15
15
  const modelHint = cfg?.model ? `模型:${cfg.model}` : '';
16
16
  const ratioHint = t.ratio || '';
17
- const fieldCount = cfg?.params_json?.fields?.length || 0;
18
- const fieldHint = fieldCount ? `字段:${cfg.params_json.fields.map((f) => f.key).join(',')}` : '';
17
+ // fields 新契约在 config 顶层,老数据在 params_json 里——两种都兜,否则自己建的模板自己列不出来
18
+ const fields = cfg?.fields || cfg?.params_json?.fields || [];
19
+ const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : '';
19
20
  const sampleHint = t.sample_video_url ? '有参考视频' : '';
20
21
  process.stderr.write(` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(20)} ${(t.category || '').padEnd(10)} ${ratioHint.padEnd(6)} ${modelHint.padEnd(30)} ${fieldHint.padEnd(24)} ${sampleHint.padEnd(10)} ${tag(t)}\n`);
21
22
  if (t.sample_video_url) {
@@ -34,11 +35,17 @@ export async function createVideoTemplate(client, opts) {
34
35
  throw new Error('--name 必填');
35
36
  if (!opts.prompt?.trim())
36
37
  throw new Error('--prompt 必填,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"');
38
+ // slug 是视频模板的硬必填(服务端 required=['zh_name','slug']),漏了必 400。
39
+ // 中文名大多是中文没法直接转 slug,不给 --slug 就生成一个 vt- 短标识,撞了让服务端报出来再换。
40
+ const slug = (opts.slug || `vt-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`).trim();
41
+ if (!/^[\w-]+$/.test(slug))
42
+ throw new Error('--slug 只能包含字母、数字、下划线和连字符');
37
43
  // 占位符必须声明 fields(中台 validateConfig 硬校验:prompt 里有 {key} 但没 fields 会被拒)
38
44
  const keys = Array.from(new Set(Array.from(opts.prompt.matchAll(/\{(\w+)\}/g), (m) => m[1])));
39
45
  const fields = keys.map((key) => ({ key, label: key }));
40
46
  const cfg = {
41
- model: opts.model || 'seedance-2',
47
+ // 默认 auto:交给中台路由按 ratio/duration 挑档次。锁死具体模型得自己保证参数配得上它
48
+ model: opts.model || 'auto',
42
49
  prompt_template: opts.prompt,
43
50
  is_default: true,
44
51
  };
@@ -46,14 +53,15 @@ export async function createVideoTemplate(client, opts) {
46
53
  cfg.fields = fields;
47
54
  if (opts.duration) {
48
55
  const d = Number(opts.duration);
49
- if (!Number.isFinite(d) || d < 4 || d > 15)
50
- throw new Error('--duration 必须是 4-15 之间的数字(秒)');
56
+ if (!Number.isFinite(d) || d < 4 || d > 30)
57
+ throw new Error('--duration 必须是 4-30 之间的数字(秒;Seedance 2.0 系上限 15,2.5 到 30,具体由中台按模型校验)');
51
58
  cfg.duration = d;
52
59
  }
53
60
  if (opts.ratio)
54
61
  cfg.aspect_ratio = opts.ratio;
55
62
  const row = await client.createVideoTemplate({
56
63
  zh_name: opts.name,
64
+ slug,
57
65
  category: opts.category,
58
66
  description: opts.description,
59
67
  sample_video_url: opts.sampleVideo || null,
@@ -61,6 +69,7 @@ export async function createVideoTemplate(client, opts) {
61
69
  generation_configs: [cfg],
62
70
  });
63
71
  process.stderr.write(`✅ 视频模板已建:${row.id}\n`);
72
+ process.stderr.write(`slug: ${slug}\n`);
64
73
  process.stderr.write(`归属:${row.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`);
65
74
  process.stderr.write(`模型: ${cfg.model} 时长: ${cfg.duration || '模板默认'} 比例: ${cfg.aspect_ratio || '模板默认'}\n`);
66
75
  if (fields.length)
@@ -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
@@ -93,6 +93,22 @@ function withClient(fn) {
93
93
  }
94
94
  };
95
95
  }
96
+ // reverse 的本地路(Ollama)不需要中台凭证,client 懒构造:只有真回落 API 才建,
97
+ // 未登录的报错也只在那时候出现
98
+ function withLazyClient(fn) {
99
+ return async (...args) => {
100
+ try {
101
+ const opts = args[args.length - 2];
102
+ const positional = args.slice(0, -2);
103
+ const getClient = () => new StudioClient(loadConfig());
104
+ await fn(getClient, ...positional, opts);
105
+ }
106
+ catch (e) {
107
+ process.stderr.write(`❌ ${e.message}\n`);
108
+ process.exit(1);
109
+ }
110
+ };
111
+ }
96
112
  // products / assets 查的是租户自己后台的数据,不是 Studio 中台的,走独立的 TenantClient
97
113
  // (见 tenant-client.ts 顶部注释),只支持租户 apiKey 身份,不支持个人 login token。
98
114
  function withTenantClient(fn) {
@@ -125,17 +141,23 @@ program
125
141
  // 不设默认值:--skill / --template 场景下要让技能/模板自己的比例生效,
126
142
  // CLI 强填默认值会把它们覆盖掉(服务端在纯 --prompt 场景已有 3:4 兜底,这里不用重复兜底)
127
143
  .option('-r, --ratio <ratio>', '宽高比: 3:4 / 9:16 / 1:1 / 4:3 / 16:9(不指定则用技能/模板自己的比例,纯 prompt 模式兜底 3:4)')
128
- .option('-m, --model <name>', '指定模型,如 gpt-image-2 / seedance-2-fast / artsdance-2-0-pro-260801')
144
+ .option('-m, --model <name>', '指定模型,如 gpt-image-2 / artsdance-2-0-pro-260801(视频不传则走 auto 路由)')
129
145
  .option('-q, --quality <level>', '质量: low / medium / high(仅 gpt-image)')
130
- .option('--ref <file>', '垫图文件路径(图片图生图,自动上传)')
131
- .option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro)')
146
+ // 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
147
+ // 用图片2作为背景」时,图片N 对应第 N --ref。commander 的 collect 保证顺序。
148
+ .option('--ref <file>', '垫图文件路径,可重复传多张(最多 5 张,顺序对应提示词里的「图片1、图片2…」)', (v, acc) => [...acc, v], [])
149
+ // 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
150
+ // "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
151
+ .option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
152
+ .option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
132
153
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
133
154
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
134
155
  .action(withClient((client, opts) => gen(client, opts)));
135
156
  program
136
157
  .command('reverse <input>')
137
- .description('读图:上传图或图片 URL,反推 SCULPT prompt,stdout 输出英文 prompt(只读图;要做成模板用 image-to-template')
138
- .action(withClient((client, input) => reverse(client, input)));
158
+ .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。主路本地 Ollama(qwen3-vl,快,无需登录);本地不可用回落中台 API(会提示较慢)。只读图;要做成模板用 image-to-template')
159
+ .option('--api', '跳过本地 Ollama,强制走中台 API(慢,需登录)')
160
+ .action(withLazyClient((getClient, input, opts) => reverse(getClient, input, opts)));
139
161
  program
140
162
  .command('image-to-template <input>')
141
163
  .description('图生模板:上传图或图片 URL → 读图 + 文字层逆向 + 变量化 → 建成可复用的图片模板(原图自动焊成参考图)')
@@ -176,10 +198,11 @@ videoTemplatesCmd
176
198
  .description('新建视频模板——归属由账号身份自动决定:租户 apiKey 建的自动归该租户,平台管理员建的是平台共享模板,个人账号不能建')
177
199
  .requiredOption('--name <zh_name>', '模板中文名')
178
200
  .requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
201
+ .option('--slug <slug>', '对外调用标识(全局唯一,视频模板硬必填);不传自动生成 vt- 前缀短标识')
179
202
  .option('--category <name>', '分类,默认「其他」')
180
203
  .option('--description <text>', '模板说明')
181
- .option('--model <name>', '视频模型,默认 seedance-2(可选 seedance-2-fast / seedance-2-mini / artsdance-2-0-pro-260801)')
182
- .option('--duration <sec>', '视频时长(秒,4-15,可选)')
204
+ .option('--model <name>', '视频模型档次,默认 auto(交给中台路由);锁死可选 artsdance-2-0-pro-260801(Seedance 2.0)/ artsdance-2-0-fast-260801 / artsdance-2-0-mini-260801 / artsdance-2-5-pro-260801(Seedance 2.5)')
205
+ .option('--duration <sec>', '视频时长(秒,4-30:Seedance 2.0 系上限 15、2.5 到 30,可选)')
183
206
  .option('--ratio <ratio>', '画面比例: 9:16 / 16:9 / 1:1 / 3:4(可选)')
184
207
  .option('--sample-video <url>', '参考视频 URL(可选,展示给用户的示例片)')
185
208
  .option('--sample-cover <url>', '封面图 URL(可选)')
@@ -0,0 +1,14 @@
1
+ import type { ReverseResult } from './client.js';
2
+ /** 本地读图模型。换档位用 MUSEAV_LOCAL_VLM 环境变量,不用改代码 */
3
+ export declare const LOCAL_VLM_MODEL: string;
4
+ export interface LocalVlmStatus {
5
+ running: boolean;
6
+ modelPresent: boolean;
7
+ host: string;
8
+ /** running=false 时的原因(给用户看的行动指引) */
9
+ reason?: string;
10
+ }
11
+ /** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
12
+ export declare function checkLocalVlm(): Promise<LocalVlmStatus>;
13
+ /** 本地逆向一张图。任何失败都抛 Error,由调用方决定回落 */
14
+ export declare function reverseLocally(filePath: string): Promise<ReverseResult>;