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 +8 -2
- package/CHANGELOG.md +15 -0
- package/README.md +21 -4
- package/dist/client.d.ts +25 -0
- package/dist/client.js +25 -5
- package/dist/commands/gen.d.ts +2 -1
- package/dist/commands/gen.js +29 -7
- package/dist/commands/reverse.d.ts +7 -2
- package/dist/commands/reverse.js +28 -2
- package/dist/commands/templates.js +2 -1
- package/dist/commands/video-templates.d.ts +2 -0
- package/dist/commands/video-templates.js +14 -5
- package/dist/compress.d.ts +13 -0
- package/dist/compress.js +97 -0
- package/dist/index.js +30 -7
- package/dist/local-vision.d.ts +14 -0
- package/dist/local-vision.js +126 -0
- package/package.json +4 -1
- package/src/client.ts +40 -5
- package/src/commands/gen.ts +32 -8
- package/src/commands/reverse.ts +36 -4
- package/src/commands/templates.ts +2 -1
- package/src/commands/video-templates.ts +15 -4
- package/src/compress.ts +110 -0
- package/src/index.ts +31 -7
- package/src/local-vision.ts +143 -0
|
@@ -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.
|
|
3
|
+
"version": "2.2.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) */
|
|
@@ -89,7 +101,12 @@ export interface TemplateOption {
|
|
|
89
101
|
model: string
|
|
90
102
|
prompt_template: string
|
|
91
103
|
ref_slots?: string[]
|
|
104
|
+
/** 表单字段声明——现行契约放 config 顶层(服务端 validateConfig 读这里) */
|
|
105
|
+
fields?: Array<{ key: string; label: string }>
|
|
106
|
+
/** 旧存法:fields 曾在 params_json 里,老模板还这么存,读时两种都要兜 */
|
|
92
107
|
params_json?: { fields?: Array<{ key: string; label: string; placeholder?: string }> }
|
|
108
|
+
duration?: number
|
|
109
|
+
aspect_ratio?: string
|
|
93
110
|
is_default?: boolean
|
|
94
111
|
}>
|
|
95
112
|
}
|
|
@@ -120,6 +137,8 @@ export interface CreateTemplateInput {
|
|
|
120
137
|
* ratio/duration/model 放在 generation_configs 每项里。 */
|
|
121
138
|
export interface CreateVideoTemplateInput {
|
|
122
139
|
zh_name: string
|
|
140
|
+
/** 对外调用标识,视频模板硬必填(服务端 validateCore required=['zh_name','slug']),全局唯一 */
|
|
141
|
+
slug: string
|
|
123
142
|
category?: string
|
|
124
143
|
description?: string
|
|
125
144
|
sample_video_url?: string | null
|
|
@@ -389,7 +408,10 @@ export class StudioClient {
|
|
|
389
408
|
if (opts.ratio) body.ratio = opts.ratio
|
|
390
409
|
if (opts.model) body.model = opts.model
|
|
391
410
|
if (opts.reference_image) body.reference_image = opts.reference_image
|
|
411
|
+
// 单双字段一起发:中台优先取复数、为空才回落单数,两个都带着更稳
|
|
412
|
+
if (opts.reference_images?.length) body.reference_images = opts.reference_images
|
|
392
413
|
if (opts.quality) body.quality = opts.quality
|
|
414
|
+
if (opts.background) body.background = opts.background
|
|
393
415
|
const r = await this.request('generate', {
|
|
394
416
|
method: 'POST',
|
|
395
417
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -500,7 +522,7 @@ export class StudioClient {
|
|
|
500
522
|
*/
|
|
501
523
|
async reverse(input: { file?: string; imageUrl?: string }): Promise<ReverseResult> {
|
|
502
524
|
if (input.file) {
|
|
503
|
-
return this.request('reverse', { method: 'POST', body: fileForm(input.file) })
|
|
525
|
+
return this.request('reverse', { method: 'POST', body: await fileForm(input.file) })
|
|
504
526
|
}
|
|
505
527
|
return this.request('reverse', {
|
|
506
528
|
method: 'POST',
|
|
@@ -516,8 +538,12 @@ export class StudioClient {
|
|
|
516
538
|
* 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
|
|
517
539
|
* 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
|
|
518
540
|
*/
|
|
541
|
+
/**
|
|
542
|
+
* 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
|
|
543
|
+
* 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
|
|
544
|
+
*/
|
|
519
545
|
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) })
|
|
546
|
+
const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) })
|
|
521
547
|
return { url: r.url, media_type: r.media_type, mime: r.mime }
|
|
522
548
|
}
|
|
523
549
|
|
|
@@ -536,7 +562,7 @@ export class StudioClient {
|
|
|
536
562
|
if (file) {
|
|
537
563
|
// multipart 分支:中台 formOptions() 对这几个键做 JSON.parse(variables 还支持逗号分隔),
|
|
538
564
|
// 所以对象/数组要自己序列化成字符串,不能直接塞进 FormData。
|
|
539
|
-
const fd = fileForm(file)
|
|
565
|
+
const fd = await fileForm(file)
|
|
540
566
|
if (variables?.length) fd.append('variables', JSON.stringify(variables))
|
|
541
567
|
if (variableLabels) fd.append('variable_labels', JSON.stringify(variableLabels))
|
|
542
568
|
if (createTemplate) fd.append('create_template', 'true')
|
|
@@ -621,9 +647,18 @@ function sleep(ms: number): Promise<void> {
|
|
|
621
647
|
* 带上原文件名:中台判类型靠字节魔数不靠这个,但文件名会进日志/对象存储的排查线索,
|
|
622
648
|
* 匿名的 "blob" 出问题时谁也认不出是哪张图。故意不设 MIME——声明的 MIME 中台本来就不信。
|
|
623
649
|
*/
|
|
624
|
-
|
|
650
|
+
/**
|
|
651
|
+
* 所有 multipart 上传的唯一入口,内置参考图压缩(见 compress.ts)。
|
|
652
|
+
* 压缩放这里而不是各调用点:uploadRef / reverse / image-to-template 都走它,
|
|
653
|
+
* 加在调用点就会漏——2026-08-16 就漏过 image-to-template,4.1MB 原图直传把任务拖挂了。
|
|
654
|
+
*/
|
|
655
|
+
async function fileForm(filePath: string): Promise<FormData> {
|
|
656
|
+
const { buffer, filename, note } = await compressForVision(filePath)
|
|
657
|
+
if (note) process.stderr.write(` ${note}\n`)
|
|
625
658
|
const fd = new FormData()
|
|
626
|
-
|
|
659
|
+
// Buffer → Uint8Array:Blob 的类型签名不收 Buffer(它可能背靠 SharedArrayBuffer)
|
|
660
|
+
const blob = buffer ? new Blob([new Uint8Array(buffer)]) : new Blob([new Uint8Array(readFileSync(filePath))])
|
|
661
|
+
fd.append('file', blob, buffer ? filename : basename(filePath))
|
|
627
662
|
return fd
|
|
628
663
|
}
|
|
629
664
|
|
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/commands/reverse.ts
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
1
|
-
/** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt
|
|
2
|
-
|
|
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(
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
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 >
|
|
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/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
|
@@ -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,18 +143,25 @@ 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 /
|
|
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
|
-
|
|
133
|
-
|
|
148
|
+
// 可重复:--ref 正面.jpg --ref 背景.jpg。顺序即语义——提示词里写「参考图片1的排版、
|
|
149
|
+
// 用图片2作为背景」时,图片N 对应第 N 个 --ref。commander 的 collect 保证顺序。
|
|
150
|
+
.option('--ref <file>', '垫图文件路径,可重复传多张(最多 5 张,顺序对应提示词里的「图片1、图片2…」)',
|
|
151
|
+
(v: string, acc: string[]) => [...acc, v], [] as string[])
|
|
152
|
+
// 透明背景是上游的 background 参数,不是提示词能表达的东西——提示词里写
|
|
153
|
+
// "transparent background" 只是在描述构图,模型照样铺一层白底。这个开关才是抠图开关。
|
|
154
|
+
.option('--transparent', '透明背景 PNG(抠掉背景,带 alpha 通道)。仅部分上游支持,不支持时中台明确报错、不会悄悄给白底图;服务端自动强制 PNG 输出(JPEG 没有 alpha 通道)')
|
|
155
|
+
.option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
|
|
134
156
|
.option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
|
|
135
157
|
.option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
|
|
136
158
|
.action(withClient((client: StudioClient, opts: any) => gen(client, opts)))
|
|
137
159
|
|
|
138
160
|
program
|
|
139
161
|
.command('reverse <input>')
|
|
140
|
-
.description('
|
|
141
|
-
.
|
|
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)))
|
|
142
165
|
|
|
143
166
|
program
|
|
144
167
|
.command('image-to-template <input>')
|
|
@@ -188,10 +211,11 @@ videoTemplatesCmd
|
|
|
188
211
|
)
|
|
189
212
|
.requiredOption('--name <zh_name>', '模板中文名')
|
|
190
213
|
.requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
|
|
214
|
+
.option('--slug <slug>', '对外调用标识(全局唯一,视频模板硬必填);不传自动生成 vt- 前缀短标识')
|
|
191
215
|
.option('--category <name>', '分类,默认「其他」')
|
|
192
216
|
.option('--description <text>', '模板说明')
|
|
193
|
-
.option('--model <name>', '
|
|
194
|
-
.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,可选)')
|
|
195
219
|
.option('--ratio <ratio>', '画面比例: 9:16 / 16:9 / 1:1 / 3:4(可选)')
|
|
196
220
|
.option('--sample-video <url>', '参考视频 URL(可选,展示给用户的示例片)')
|
|
197
221
|
.option('--sample-cover <url>', '封面图 URL(可选)')
|