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,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
|
+
}
|