museav-cli 2.1.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
@@ -172,8 +172,8 @@ museav gen --prompt '把这只鞋抠成透明底' --ref shoe.jpg --transparent
172
172
  # (白底图看起来完全正常,静默降级只会让你以为提示词没写对,反复重试)
173
173
  # · 会强制 PNG 输出:JPEG / 有损 WebP 没有 alpha 通道,装不下透明
174
174
 
175
- # 文生视频(模型如 seedance-2-fast / artsdance-2-0-pro-260801,自动轮询直到完成)
176
- museav gen --video --prompt '一只橘猫在窗台上伸懒腰,阳光洒进来,电影感' --model seedance-2-fast --ratio 9:16
175
+ # 文生视频(不传 --model auto 路由;锁死档次用 artsdance-2-0-pro-260801 这类代号,自动轮询直到完成)
176
+ museav gen --video --prompt '一只橘猫在窗台上伸懒腰,阳光洒进来,电影感' --ratio 9:16
177
177
 
178
178
  # 图生视频(--image 传首帧图,自动上传)
179
179
  museav gen --video --image logo.png --prompt 'logo 缓缓发光,背景渐暗' --ratio 1:1
@@ -237,17 +237,24 @@ museav gen --template "$ID" --fields '{"artist":"..."}'
237
237
 
238
238
  ### 图片逆向 `reverse`
239
239
 
240
- 上传一张图,中台用 **SCULPT 六要素**(主体/构图/世界观/光影/输出/质感)逆推出图 prompt,可以直接拿去再出一张同风格的:
240
+ 逆推出图 prompt,可以直接拿去再出一张同风格的。**主路是本地 Ollama(qwen3-vl)**:快、零成本、无需登录;本地不可用时自动回落中台 API(SCULPT 六要素:主体/构图/世界观/光影/输出/质感),回落时会明确提示较慢:
241
241
 
242
242
  ```bash
243
- # 本地文件或图片 URL 都行
243
+ # 本地文件走本地模型(推荐,无需登录)
244
244
  museav reverse photo.png
245
+
246
+ # 图片 URL 没法喂本地模型,走中台 API(需登录)
245
247
  museav reverse https://example.com/photo.png
246
248
 
249
+ # 强制走中台 API(跳过本地,慢,需登录)
250
+ museav reverse photo.png --api
251
+
247
252
  # 逆向 + 出图,一条龙
248
253
  museav gen --prompt "$(museav reverse photo.png)"
249
254
  ```
250
255
 
256
+ 本地路依赖:Ollama 运行中(`brew services start ollama`)+ 模型在位(`ollama pull qwen3-vl:8b`,换档位设 `MUSEAV_LOCAL_VLM`,自定地址设 `OLLAMA_HOST`)。缺哪个都会提示对应命令并回落 API。
257
+
251
258
  分析详情打到 stderr(人看),**stdout 只输出英文 prompt**(机器用,方便管道)。
252
259
 
253
260
  `reverse` **只读图**,不会顺手帮你建模板。要把图做成模板看下一节——中台 2026-08-16 把这两件事
package/dist/client.d.ts CHANGED
@@ -51,6 +51,12 @@ export interface TemplateOption {
51
51
  model: string;
52
52
  prompt_template: string;
53
53
  ref_slots?: string[];
54
+ /** 表单字段声明——现行契约放 config 顶层(服务端 validateConfig 读这里) */
55
+ fields?: Array<{
56
+ key: string;
57
+ label: string;
58
+ }>;
59
+ /** 旧存法:fields 曾在 params_json 里,老模板还这么存,读时两种都要兜 */
54
60
  params_json?: {
55
61
  fields?: Array<{
56
62
  key: string;
@@ -58,6 +64,8 @@ export interface TemplateOption {
58
64
  placeholder?: string;
59
65
  }>;
60
66
  };
67
+ duration?: number;
68
+ aspect_ratio?: string;
61
69
  is_default?: boolean;
62
70
  }>;
63
71
  }
@@ -89,6 +97,8 @@ export interface CreateTemplateInput {
89
97
  * ratio/duration/model 放在 generation_configs 每项里。 */
90
98
  export interface CreateVideoTemplateInput {
91
99
  zh_name: string;
100
+ /** 对外调用标识,视频模板硬必填(服务端 validateCore required=['zh_name','slug']),全局唯一 */
101
+ slug: string;
92
102
  category?: string;
93
103
  description?: string;
94
104
  sample_video_url?: string | null;
@@ -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)
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,7 +141,7 @@ 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
146
  // 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
131
147
  // 用图片2作为背景」时,图片N 对应第 N 个 --ref。commander 的 collect 保证顺序。
@@ -133,14 +149,15 @@ program
133
149
  // 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
134
150
  // "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
135
151
  .option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
136
- .option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro')
152
+ .option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
137
153
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
138
154
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
139
155
  .action(withClient((client, opts) => gen(client, opts)));
140
156
  program
141
157
  .command('reverse <input>')
142
- .description('读图:上传图或图片 URL,反推 SCULPT prompt,stdout 输出英文 prompt(只读图;要做成模板用 image-to-template')
143
- .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)));
144
161
  program
145
162
  .command('image-to-template <input>')
146
163
  .description('图生模板:上传图或图片 URL → 读图 + 文字层逆向 + 变量化 → 建成可复用的图片模板(原图自动焊成参考图)')
@@ -181,10 +198,11 @@ videoTemplatesCmd
181
198
  .description('新建视频模板——归属由账号身份自动决定:租户 apiKey 建的自动归该租户,平台管理员建的是平台共享模板,个人账号不能建')
182
199
  .requiredOption('--name <zh_name>', '模板中文名')
183
200
  .requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
201
+ .option('--slug <slug>', '对外调用标识(全局唯一,视频模板硬必填);不传自动生成 vt- 前缀短标识')
184
202
  .option('--category <name>', '分类,默认「其他」')
185
203
  .option('--description <text>', '模板说明')
186
- .option('--model <name>', '视频模型,默认 seedance-2(可选 seedance-2-fast / seedance-2-mini / artsdance-2-0-pro-260801)')
187
- .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,可选)')
188
206
  .option('--ratio <ratio>', '画面比例: 9:16 / 16:9 / 1:1 / 3:4(可选)')
189
207
  .option('--sample-video <url>', '参考视频 URL(可选,展示给用户的示例片)')
190
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>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 本地视觉模型(Ollama + qwen3-vl)—— reverse 的主路。
3
+ * 中台 API 逆向一次要十几秒到几十秒,本地 8b 量化模型在 Apple Silicon 上更快且零成本;
4
+ * API 降级为回落路(commands/reverse.ts 负责切换与提示)。
5
+ * 提示词与返回结构从中台 _reverse-core.js / reverse-template.js 移植,保证两条路产出同构。
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import { compressForVision } from './compress.js';
9
+ /** 本地读图模型。换档位用 MUSEAV_LOCAL_VLM 环境变量,不用改代码 */
10
+ export const LOCAL_VLM_MODEL = process.env.MUSEAV_LOCAL_VLM || 'qwen3-vl:8b';
11
+ const ALLOWED_RATIOS = ['3:4', '9:16', '1:1', '4:3', '16:9'];
12
+ // OLLAMA_HOST 生态里带不带 scheme、带不带尾斜杠的写法都有
13
+ function ollamaHost() {
14
+ let host = process.env.OLLAMA_HOST || 'http://localhost:11434';
15
+ if (!/^https?:\/\//.test(host))
16
+ host = `http://${host}`;
17
+ return host.replace(/\/+$/, '');
18
+ }
19
+ /** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
20
+ export async function checkLocalVlm() {
21
+ const host = ollamaHost();
22
+ try {
23
+ const resp = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
24
+ if (!resp.ok) {
25
+ return { running: false, modelPresent: false, host, reason: `Ollama 探活返回 HTTP ${resp.status}` };
26
+ }
27
+ const tags = (await resp.json());
28
+ const names = (tags.models || []).map((m) => m.name || '');
29
+ if (!names.includes(LOCAL_VLM_MODEL)) {
30
+ return { running: true, modelPresent: false, host, reason: `模型未拉取,执行: ollama pull ${LOCAL_VLM_MODEL}` };
31
+ }
32
+ return { running: true, modelPresent: true, host };
33
+ }
34
+ catch {
35
+ return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),启动: ollama serve 或 brew services start ollama` };
36
+ }
37
+ }
38
+ /** SCULPT 系统提示词 —— 从中台 reverse-template.js 移植。本地路只做纯读图,
39
+ * 中台提示词里的 genre / body_md(给 image-to-template 用的)在 ReverseResult 里
40
+ * 根本不消费,本地砍掉这两项省几百个输出 token——输出长度直接决定本地推理耗时 */
41
+ function sculptSystemPrompt() {
42
+ return (`你是一位专业的 AI 图像逆向工程师。请分析这张图片,用 SCULPT 六要素框架逆推生成该图片所需的 prompt。` +
43
+ `严格输出 JSON,不要输出任何其他文字:\n` +
44
+ `{\n` +
45
+ ` "sculpt": {\n` +
46
+ ` "subject": "主体描述 — 画面中的人物/物体/场景,包括外貌、姿态、服饰",\n` +
47
+ ` "composition": "构图描述 — 视角、布局、留白、视觉引导线",\n` +
48
+ ` "universe": "世界观 — 时代背景、艺术风格、整体氛围",\n` +
49
+ ` "light": "光影描述 — 光源方向、色温、明暗对比、光影效果",\n` +
50
+ ` "print": "输出特性 — 比例、色调倾向、对比度、饱和度",\n` +
51
+ ` "texture": "质感描述 — 材质、表面纹理、细节精度"\n` +
52
+ ` },\n` +
53
+ ` "prompt": "整合 SCULPT 六要素后的完整英文 prompt(适合 AI 图像生成模型)",\n` +
54
+ ` "prompt_cn": "对应中文 prompt",\n` +
55
+ ` "style_tags": ["2-4 个关键风格标签"],\n` +
56
+ ` "aspect_ratio": "推荐比例,从 3:4|9:16|1:1|4:3|16:9 中按图片比例选一个",\n` +
57
+ ` "zh_name": "4-8 字风格名(供技能命名)",\n` +
58
+ ` "description": "一句话描述该风格"` +
59
+ `\n}\n要求:prompt 必须是英文,详细且精确,覆盖全部六个维度;prompt_cn 为对应中文;只输出 JSON。`);
60
+ }
61
+ /** 本地逆向一张图。任何失败都抛 Error,由调用方决定回落 */
62
+ export async function reverseLocally(filePath) {
63
+ // 复用上传同款压缩:图小不仅传得快,本地 VLM 推理也快
64
+ const { buffer, note } = await compressForVision(filePath);
65
+ if (note)
66
+ process.stderr.write(` ${note}\n`);
67
+ const bytes = buffer ?? (await readFile(filePath));
68
+ const b64 = Buffer.from(bytes).toString('base64');
69
+ const payload = {
70
+ model: LOCAL_VLM_MODEL,
71
+ messages: [
72
+ { role: 'system', content: sculptSystemPrompt() },
73
+ { role: 'user', content: '用 SCULPT 六要素分析这张图,逆推出图 prompt', images: [b64] },
74
+ ],
75
+ stream: false,
76
+ };
77
+ // 8b 视觉推理单张图几十秒量级,给足余量
78
+ const resp = await fetch(`${ollamaHost()}/api/chat`, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify(payload),
82
+ signal: AbortSignal.timeout(5 * 60 * 1000),
83
+ });
84
+ if (!resp.ok) {
85
+ throw new Error(`Ollama 返回 HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
86
+ }
87
+ const out = (await resp.json());
88
+ const content = out.message?.content || '';
89
+ if (!content.trim())
90
+ throw new Error('本地模型返回空内容');
91
+ return normalizeSculpt(parseJsonLoose(content));
92
+ }
93
+ /** 视觉模型「只输出 JSON」的承诺不可信:剥 ```json 围栏、截首尾大括号 */
94
+ function parseJsonLoose(text) {
95
+ let t = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
96
+ const start = t.indexOf('{');
97
+ const end = t.lastIndexOf('}');
98
+ if (start >= 0 && end > start)
99
+ t = t.slice(start, end + 1);
100
+ return JSON.parse(t);
101
+ }
102
+ /** 与中台 basePayload 同构的归一化:超长截断、非法比例兜底 3:4 */
103
+ function normalizeSculpt(parsed) {
104
+ const sculptIn = (parsed.sculpt || {});
105
+ const sculpt = {};
106
+ for (const key of ['subject', 'composition', 'universe', 'light', 'print', 'texture']) {
107
+ sculpt[key] = String(sculptIn[key] || '').slice(0, 500);
108
+ }
109
+ const ratio = ALLOWED_RATIOS.includes(parsed.aspect_ratio)
110
+ ? parsed.aspect_ratio
111
+ : ALLOWED_RATIOS.includes(parsed.ratio)
112
+ ? parsed.ratio
113
+ : '3:4';
114
+ return {
115
+ ok: true,
116
+ sculpt,
117
+ prompt: String(parsed.prompt || '').slice(0, 2000),
118
+ prompt_cn: String(parsed.prompt_cn || '').slice(0, 2000),
119
+ style_tags: Array.isArray(parsed.style_tags)
120
+ ? parsed.style_tags.slice(0, 6).map((t) => String(t).slice(0, 30))
121
+ : [],
122
+ aspect_ratio: ratio,
123
+ zh_name: String(parsed.zh_name || '裂变风格').slice(0, 24),
124
+ description: String(parsed.description || '').slice(0, 200),
125
+ };
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.ts CHANGED
@@ -101,7 +101,12 @@ export interface TemplateOption {
101
101
  model: string
102
102
  prompt_template: string
103
103
  ref_slots?: string[]
104
+ /** 表单字段声明——现行契约放 config 顶层(服务端 validateConfig 读这里) */
105
+ fields?: Array<{ key: string; label: string }>
106
+ /** 旧存法:fields 曾在 params_json 里,老模板还这么存,读时两种都要兜 */
104
107
  params_json?: { fields?: Array<{ key: string; label: string; placeholder?: string }> }
108
+ duration?: number
109
+ aspect_ratio?: string
105
110
  is_default?: boolean
106
111
  }>
107
112
  }
@@ -132,6 +137,8 @@ export interface CreateTemplateInput {
132
137
  * ratio/duration/model 放在 generation_configs 每项里。 */
133
138
  export interface CreateVideoTemplateInput {
134
139
  zh_name: string
140
+ /** 对外调用标识,视频模板硬必填(服务端 validateCore required=['zh_name','slug']),全局唯一 */
141
+ slug: string
135
142
  category?: string
136
143
  description?: string
137
144
  sample_video_url?: string | null
@@ -1,11 +1,43 @@
1
- /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt) */
2
- import type { StudioClient } from '../client.js'
1
+ /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
2
+ * 主路是本地 Ollama(qwen3-vl),快、零成本、无需登录;中台 API 是回落路,走回落时会明确提示较慢。
3
+ * client 懒构造(getClient):本地路成功就完全不碰中台凭证。
4
+ * 本地系统的 AI 能力统一收口在这个 CLI,reverse 是第一个本地化的能力。 */
5
+ import type { StudioClient, ReverseResult } from '../client.js'
6
+ import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js'
3
7
 
4
- export async function reverse(client: StudioClient, input: string): Promise<void> {
5
- // 输入是文件路径还是 URL
8
+ export async function reverse(
9
+ getClient: () => StudioClient,
10
+ input: string,
11
+ opts: { api?: boolean } = {},
12
+ ): Promise<void> {
6
13
  const isUrl = /^https?:\/\//.test(input)
14
+
15
+ if (!opts.api && !isUrl) {
16
+ const status = await checkLocalVlm()
17
+ if (status.running && status.modelPresent) {
18
+ try {
19
+ const start = Date.now()
20
+ const result = await reverseLocally(input)
21
+ process.stderr.write(`✓ 本地 Ollama(${LOCAL_VLM_MODEL})用时 ${((Date.now() - start) / 1000).toFixed(1)}s\n`)
22
+ renderReverse(result)
23
+ return
24
+ } catch (e) {
25
+ process.stderr.write(`⚠ 本地读图失败(${e instanceof Error ? e.message : e}),回落中台 API —— 速度较慢,请耐心等待\n`)
26
+ }
27
+ } else {
28
+ process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`)
29
+ }
30
+ } else if (!opts.api && isUrl) {
31
+ process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`)
32
+ }
33
+
34
+ const client = getClient()
7
35
  const result = await client.reverse(isUrl ? { imageUrl: input } : { file: input })
36
+ renderReverse(result)
37
+ }
8
38
 
39
+ /** 两条路产出同构,渲染只写一份 */
40
+ function renderReverse(result: ReverseResult): void {
9
41
  process.stderr.write(`✅ 逆向完成\n\n`)
10
42
  process.stderr.write(`风格: ${result.zh_name || '-'} 比例: ${result.aspect_ratio}\n`)
11
43
  process.stderr.write(`标签: ${result.style_tags.join(', ')}\n\n`)
@@ -19,7 +19,8 @@ export async function templates(client: StudioClient, opts: { category?: string;
19
19
  process.stderr.write(`可用模板(${list.length} 个):\n`)
20
20
  for (const t of list) {
21
21
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
22
- const fields = cfg?.params_json?.fields || []
22
+ // fields 新契约在 config 顶层(CLI 自己 create 就写顶层),老数据在 params_json 里——两种都兜
23
+ const fields = cfg?.fields || cfg?.params_json?.fields || []
23
24
  const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : ''
24
25
  process.stderr.write(
25
26
  ` ${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,8 +19,9 @@ export async function videoTemplates(client: StudioClient, opts: { category?: st
19
19
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
20
20
  const modelHint = cfg?.model ? `模型:${cfg.model}` : ''
21
21
  const ratioHint = t.ratio || ''
22
- const fieldCount = cfg?.params_json?.fields?.length || 0
23
- const fieldHint = fieldCount ? `字段:${cfg!.params_json!.fields!.map((f) => f.key).join(',')}` : ''
22
+ // fields 新契约在 config 顶层,老数据在 params_json 里——两种都兜,否则自己建的模板自己列不出来
23
+ const fields = cfg?.fields || cfg?.params_json?.fields || []
24
+ const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : ''
24
25
  const sampleHint = t.sample_video_url ? '有参考视频' : ''
25
26
  process.stderr.write(
26
27
  ` ${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`,
@@ -36,6 +37,8 @@ export async function videoTemplates(client: StudioClient, opts: { category?: st
36
37
 
37
38
  interface CreateVideoTemplateOpts {
38
39
  name: string
40
+ /** 对外调用标识,全局唯一。不给就自动生成一个(vt- 前缀) */
41
+ slug?: string
39
42
  prompt: string
40
43
  category?: string
41
44
  description?: string
@@ -53,25 +56,32 @@ export async function createVideoTemplate(client: StudioClient, opts: CreateVide
53
56
  if (!opts.name?.trim()) throw new Error('--name 必填')
54
57
  if (!opts.prompt?.trim()) throw new Error('--prompt 必填,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
55
58
 
59
+ // slug 是视频模板的硬必填(服务端 required=['zh_name','slug']),漏了必 400。
60
+ // 中文名大多是中文没法直接转 slug,不给 --slug 就生成一个 vt- 短标识,撞了让服务端报出来再换。
61
+ const slug = (opts.slug || `vt-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`).trim()
62
+ if (!/^[\w-]+$/.test(slug)) throw new Error('--slug 只能包含字母、数字、下划线和连字符')
63
+
56
64
  // 占位符必须声明 fields(中台 validateConfig 硬校验:prompt 里有 {key} 但没 fields 会被拒)
57
65
  const keys = Array.from(new Set(Array.from(opts.prompt.matchAll(/\{(\w+)\}/g), (m) => m[1])))
58
66
  const fields = keys.map((key) => ({ key, label: key }))
59
67
 
60
68
  const cfg: Record<string, unknown> = {
61
- model: opts.model || 'seedance-2',
69
+ // 默认 auto:交给中台路由按 ratio/duration 挑档次。锁死具体模型得自己保证参数配得上它
70
+ model: opts.model || 'auto',
62
71
  prompt_template: opts.prompt,
63
72
  is_default: true,
64
73
  }
65
74
  if (fields.length) cfg.fields = fields
66
75
  if (opts.duration) {
67
76
  const d = Number(opts.duration)
68
- if (!Number.isFinite(d) || d < 4 || d > 15) throw new Error('--duration 必须是 4-15 之间的数字(秒)')
77
+ if (!Number.isFinite(d) || d < 4 || d > 30) throw new Error('--duration 必须是 4-30 之间的数字(秒;Seedance 2.0 系上限 15,2.5 到 30,具体由中台按模型校验)')
69
78
  cfg.duration = d
70
79
  }
71
80
  if (opts.ratio) cfg.aspect_ratio = opts.ratio
72
81
 
73
82
  const row = await client.createVideoTemplate({
74
83
  zh_name: opts.name,
84
+ slug,
75
85
  category: opts.category,
76
86
  description: opts.description,
77
87
  sample_video_url: opts.sampleVideo || null,
@@ -80,6 +90,7 @@ export async function createVideoTemplate(client: StudioClient, opts: CreateVide
80
90
  })
81
91
 
82
92
  process.stderr.write(`✅ 视频模板已建:${row.id}\n`)
93
+ process.stderr.write(`slug: ${slug}\n`)
83
94
  process.stderr.write(`归属:${row.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`)
84
95
  process.stderr.write(`模型: ${cfg.model} 时长: ${cfg.duration || '模板默认'} 比例: ${cfg.aspect_ratio || '模板默认'}\n`)
85
96
  if (fields.length) process.stderr.write(`占位符字段: ${fields.map((f) => f.key).join(', ')}\n`)
package/src/index.ts CHANGED
@@ -93,6 +93,22 @@ function withClient<T extends (...args: any[]) => Promise<any>>(fn: T) {
93
93
  }
94
94
  }
95
95
 
96
+ // reverse 的本地路(Ollama)不需要中台凭证,client 懒构造:只有真回落 API 才建,
97
+ // 未登录的报错也只在那时候出现
98
+ function withLazyClient(fn: (getClient: () => StudioClient, ...args: any[]) => Promise<any>) {
99
+ return async (...args: any[]) => {
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
+ } catch (e) {
106
+ process.stderr.write(`❌ ${(e as Error).message}\n`)
107
+ process.exit(1)
108
+ }
109
+ }
110
+ }
111
+
96
112
  // products / assets 查的是租户自己后台的数据,不是 Studio 中台的,走独立的 TenantClient
97
113
  // (见 tenant-client.ts 顶部注释),只支持租户 apiKey 身份,不支持个人 login token。
98
114
  function withTenantClient<T extends (...args: any[]) => Promise<any>>(fn: T) {
@@ -127,7 +143,7 @@ program
127
143
  // 不设默认值:--skill / --template 场景下要让技能/模板自己的比例生效,
128
144
  // CLI 强填默认值会把它们覆盖掉(服务端在纯 --prompt 场景已有 3:4 兜底,这里不用重复兜底)
129
145
  .option('-r, --ratio <ratio>', '宽高比: 3:4 / 9:16 / 1:1 / 4:3 / 16:9(不指定则用技能/模板自己的比例,纯 prompt 模式兜底 3:4)')
130
- .option('-m, --model <name>', '指定模型,如 gpt-image-2 / seedance-2-fast / artsdance-2-0-pro-260801')
146
+ .option('-m, --model <name>', '指定模型,如 gpt-image-2 / artsdance-2-0-pro-260801(视频不传则走 auto 路由)')
131
147
  .option('-q, --quality <level>', '质量: low / medium / high(仅 gpt-image)')
132
148
  // 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
133
149
  // 用图片2作为背景」时,图片N 对应第 N 个 --ref。commander 的 collect 保证顺序。
@@ -136,15 +152,16 @@ program
136
152
  // 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
137
153
  // "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
138
154
  .option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
139
- .option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro')
155
+ .option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
140
156
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
141
157
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
142
158
  .action(withClient((client: StudioClient, opts: any) => gen(client, opts)))
143
159
 
144
160
  program
145
161
  .command('reverse <input>')
146
- .description('读图:上传图或图片 URL,反推 SCULPT prompt,stdout 输出英文 prompt(只读图;要做成模板用 image-to-template')
147
- .action(withClient((client: StudioClient, input: string) => reverse(client, input)))
162
+ .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。主路本地 Ollama(qwen3-vl,快,无需登录);本地不可用回落中台 API(会提示较慢)。只读图;要做成模板用 image-to-template')
163
+ .option('--api', '跳过本地 Ollama,强制走中台 API(慢,需登录)')
164
+ .action(withLazyClient((getClient: () => StudioClient, input: string, opts: any) => reverse(getClient, input, opts)))
148
165
 
149
166
  program
150
167
  .command('image-to-template <input>')
@@ -194,10 +211,11 @@ videoTemplatesCmd
194
211
  )
195
212
  .requiredOption('--name <zh_name>', '模板中文名')
196
213
  .requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
214
+ .option('--slug <slug>', '对外调用标识(全局唯一,视频模板硬必填);不传自动生成 vt- 前缀短标识')
197
215
  .option('--category <name>', '分类,默认「其他」')
198
216
  .option('--description <text>', '模板说明')
199
- .option('--model <name>', '视频模型,默认 seedance-2(可选 seedance-2-fast / seedance-2-mini / artsdance-2-0-pro-260801)')
200
- .option('--duration <sec>', '视频时长(秒,4-15,可选)')
217
+ .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)')
218
+ .option('--duration <sec>', '视频时长(秒,4-30:Seedance 2.0 系上限 15、2.5 到 30,可选)')
201
219
  .option('--ratio <ratio>', '画面比例: 9:16 / 16:9 / 1:1 / 3:4(可选)')
202
220
  .option('--sample-video <url>', '参考视频 URL(可选,展示给用户的示例片)')
203
221
  .option('--sample-cover <url>', '封面图 URL(可选)')
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 本地视觉模型(Ollama + qwen3-vl)—— reverse 的主路。
3
+ * 中台 API 逆向一次要十几秒到几十秒,本地 8b 量化模型在 Apple Silicon 上更快且零成本;
4
+ * API 降级为回落路(commands/reverse.ts 负责切换与提示)。
5
+ * 提示词与返回结构从中台 _reverse-core.js / reverse-template.js 移植,保证两条路产出同构。
6
+ */
7
+ import { readFile } from 'node:fs/promises'
8
+ import { compressForVision } from './compress.js'
9
+ import type { ReverseResult } from './client.js'
10
+
11
+ /** 本地读图模型。换档位用 MUSEAV_LOCAL_VLM 环境变量,不用改代码 */
12
+ export const LOCAL_VLM_MODEL = process.env.MUSEAV_LOCAL_VLM || 'qwen3-vl:8b'
13
+
14
+ const ALLOWED_RATIOS = ['3:4', '9:16', '1:1', '4:3', '16:9']
15
+
16
+ // OLLAMA_HOST 生态里带不带 scheme、带不带尾斜杠的写法都有
17
+ function ollamaHost(): string {
18
+ let host = process.env.OLLAMA_HOST || 'http://localhost:11434'
19
+ if (!/^https?:\/\//.test(host)) host = `http://${host}`
20
+ return host.replace(/\/+$/, '')
21
+ }
22
+
23
+ export interface LocalVlmStatus {
24
+ running: boolean
25
+ modelPresent: boolean
26
+ host: string
27
+ /** running=false 时的原因(给用户看的行动指引) */
28
+ reason?: string
29
+ }
30
+
31
+ /** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
32
+ export async function checkLocalVlm(): Promise<LocalVlmStatus> {
33
+ const host = ollamaHost()
34
+ try {
35
+ const resp = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) })
36
+ if (!resp.ok) {
37
+ return { running: false, modelPresent: false, host, reason: `Ollama 探活返回 HTTP ${resp.status}` }
38
+ }
39
+ const tags = (await resp.json()) as { models?: Array<{ name?: string }> }
40
+ const names = (tags.models || []).map((m) => m.name || '')
41
+ if (!names.includes(LOCAL_VLM_MODEL)) {
42
+ return { running: true, modelPresent: false, host, reason: `模型未拉取,执行: ollama pull ${LOCAL_VLM_MODEL}` }
43
+ }
44
+ return { running: true, modelPresent: true, host }
45
+ } catch {
46
+ return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),启动: ollama serve 或 brew services start ollama` }
47
+ }
48
+ }
49
+
50
+ /** SCULPT 系统提示词 —— 从中台 reverse-template.js 移植。本地路只做纯读图,
51
+ * 中台提示词里的 genre / body_md(给 image-to-template 用的)在 ReverseResult 里
52
+ * 根本不消费,本地砍掉这两项省几百个输出 token——输出长度直接决定本地推理耗时 */
53
+ function sculptSystemPrompt(): string {
54
+ return (
55
+ `你是一位专业的 AI 图像逆向工程师。请分析这张图片,用 SCULPT 六要素框架逆推生成该图片所需的 prompt。` +
56
+ `严格输出 JSON,不要输出任何其他文字:\n` +
57
+ `{\n` +
58
+ ` "sculpt": {\n` +
59
+ ` "subject": "主体描述 — 画面中的人物/物体/场景,包括外貌、姿态、服饰",\n` +
60
+ ` "composition": "构图描述 — 视角、布局、留白、视觉引导线",\n` +
61
+ ` "universe": "世界观 — 时代背景、艺术风格、整体氛围",\n` +
62
+ ` "light": "光影描述 — 光源方向、色温、明暗对比、光影效果",\n` +
63
+ ` "print": "输出特性 — 比例、色调倾向、对比度、饱和度",\n` +
64
+ ` "texture": "质感描述 — 材质、表面纹理、细节精度"\n` +
65
+ ` },\n` +
66
+ ` "prompt": "整合 SCULPT 六要素后的完整英文 prompt(适合 AI 图像生成模型)",\n` +
67
+ ` "prompt_cn": "对应中文 prompt",\n` +
68
+ ` "style_tags": ["2-4 个关键风格标签"],\n` +
69
+ ` "aspect_ratio": "推荐比例,从 3:4|9:16|1:1|4:3|16:9 中按图片比例选一个",\n` +
70
+ ` "zh_name": "4-8 字风格名(供技能命名)",\n` +
71
+ ` "description": "一句话描述该风格"` +
72
+ `\n}\n要求:prompt 必须是英文,详细且精确,覆盖全部六个维度;prompt_cn 为对应中文;只输出 JSON。`
73
+ )
74
+ }
75
+
76
+ /** 本地逆向一张图。任何失败都抛 Error,由调用方决定回落 */
77
+ export async function reverseLocally(filePath: string): Promise<ReverseResult> {
78
+ // 复用上传同款压缩:图小不仅传得快,本地 VLM 推理也快
79
+ const { buffer, note } = await compressForVision(filePath)
80
+ if (note) process.stderr.write(` ${note}\n`)
81
+ const bytes = buffer ?? (await readFile(filePath))
82
+ const b64 = Buffer.from(bytes).toString('base64')
83
+
84
+ const payload = {
85
+ model: LOCAL_VLM_MODEL,
86
+ messages: [
87
+ { role: 'system', content: sculptSystemPrompt() },
88
+ { role: 'user', content: '用 SCULPT 六要素分析这张图,逆推出图 prompt', images: [b64] },
89
+ ],
90
+ stream: false,
91
+ }
92
+
93
+ // 8b 视觉推理单张图几十秒量级,给足余量
94
+ const resp = await fetch(`${ollamaHost()}/api/chat`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify(payload),
98
+ signal: AbortSignal.timeout(5 * 60 * 1000),
99
+ })
100
+ if (!resp.ok) {
101
+ throw new Error(`Ollama 返回 HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`)
102
+ }
103
+ const out = (await resp.json()) as { message?: { content?: string } }
104
+ const content = out.message?.content || ''
105
+ if (!content.trim()) throw new Error('本地模型返回空内容')
106
+
107
+ return normalizeSculpt(parseJsonLoose(content))
108
+ }
109
+
110
+ /** 视觉模型「只输出 JSON」的承诺不可信:剥 ```json 围栏、截首尾大括号 */
111
+ function parseJsonLoose(text: string): Record<string, unknown> {
112
+ let t = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
113
+ const start = t.indexOf('{')
114
+ const end = t.lastIndexOf('}')
115
+ if (start >= 0 && end > start) t = t.slice(start, end + 1)
116
+ return JSON.parse(t) as Record<string, unknown>
117
+ }
118
+
119
+ /** 与中台 basePayload 同构的归一化:超长截断、非法比例兜底 3:4 */
120
+ function normalizeSculpt(parsed: Record<string, unknown>): ReverseResult {
121
+ const sculptIn = (parsed.sculpt || {}) as Record<string, unknown>
122
+ const sculpt: Record<string, string> = {}
123
+ for (const key of ['subject', 'composition', 'universe', 'light', 'print', 'texture']) {
124
+ sculpt[key] = String(sculptIn[key] || '').slice(0, 500)
125
+ }
126
+ const ratio = ALLOWED_RATIOS.includes(parsed.aspect_ratio as string)
127
+ ? (parsed.aspect_ratio as string)
128
+ : ALLOWED_RATIOS.includes(parsed.ratio as string)
129
+ ? (parsed.ratio as string)
130
+ : '3:4'
131
+ return {
132
+ ok: true,
133
+ sculpt,
134
+ prompt: String(parsed.prompt || '').slice(0, 2000),
135
+ prompt_cn: String(parsed.prompt_cn || '').slice(0, 2000),
136
+ style_tags: Array.isArray(parsed.style_tags)
137
+ ? (parsed.style_tags as unknown[]).slice(0, 6).map((t) => String(t).slice(0, 30))
138
+ : [],
139
+ aspect_ratio: ratio,
140
+ zh_name: String(parsed.zh_name || '裂变风格').slice(0, 24),
141
+ description: String(parsed.description || '').slice(0, 200),
142
+ }
143
+ }