museav-cli 2.2.0 → 2.3.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 +9 -0
- package/CHANGELOG.md +16 -0
- package/README.md +41 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.js +39 -0
- package/dist/commands/gen.d.ts +1 -0
- package/dist/commands/gen.js +12 -0
- package/dist/commands/img-tools.d.ts +16 -0
- package/dist/commands/img-tools.js +81 -0
- package/dist/commands/jobs.d.ts +1 -0
- package/dist/commands/jobs.js +7 -1
- package/dist/commands/projects.d.ts +21 -0
- package/dist/commands/projects.js +69 -0
- package/dist/index.js +61 -1
- package/dist/local-bg.d.ts +17 -0
- package/dist/local-bg.js +143 -0
- package/dist/local-vision.js +9 -1
- package/package.json +3 -2
- package/src/client.ts +87 -13
- package/src/commands/gen.ts +14 -0
- package/src/commands/img-tools.ts +95 -0
- package/src/commands/jobs.ts +8 -2
- package/src/commands/projects.ts +80 -0
- package/src/index.ts +69 -1
- package/src/local-bg.ts +142 -0
- package/src/local-vision.ts +8 -1
package/dist/local-bg.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地抠图(去背景)—— remove-bg 的核心实现。
|
|
3
|
+
* 模型走 ONNX(ISNet / U2Net,均 Apache-2.0),推理走 onnxruntime-node(MIT),
|
|
4
|
+
* 前后处理走 sharp —— 整条链路许可证干净(imgly 那个 npm 包是 AGPL,不进依赖),
|
|
5
|
+
* 且三个依赖在 macOS / Windows / Linux 都有预编译,无平台特化代码。
|
|
6
|
+
* 模型文件首次使用时下载到 ~/.museav-models/ 缓存(一次性 ~170MB)。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, writeFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
export const BG_MODELS = {
|
|
12
|
+
isnet: {
|
|
13
|
+
file: 'isnet-general-use.onnx',
|
|
14
|
+
// rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
|
|
15
|
+
url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
|
|
16
|
+
label: 'ISNet(通用,质量优先)',
|
|
17
|
+
},
|
|
18
|
+
u2net: {
|
|
19
|
+
file: 'u2net.onnx',
|
|
20
|
+
url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
|
|
21
|
+
label: 'U2Net(经典通用)',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const MODEL_DIR = join(homedir(), '.museav-models');
|
|
25
|
+
const INPUT_EDGE = 1024;
|
|
26
|
+
function modelPath(key) {
|
|
27
|
+
return join(MODEL_DIR, BG_MODELS[key].file);
|
|
28
|
+
}
|
|
29
|
+
/** 模型在位返回路径;不在则下载(流式,进度打 stderr)。下载失败抛 Error */
|
|
30
|
+
export async function ensureBgModel(key) {
|
|
31
|
+
const dest = modelPath(key);
|
|
32
|
+
try {
|
|
33
|
+
const s = await stat(dest);
|
|
34
|
+
if (s.size > 10_000_000)
|
|
35
|
+
return dest; // 正常模型都是百 MB 级;太小的文件视为残缺重下
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// 不存在,走下载
|
|
39
|
+
}
|
|
40
|
+
await mkdir(MODEL_DIR, { recursive: true });
|
|
41
|
+
const def = BG_MODELS[key];
|
|
42
|
+
process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`);
|
|
43
|
+
const resp = await fetch(def.url);
|
|
44
|
+
if (!resp.ok || !resp.body)
|
|
45
|
+
throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`);
|
|
46
|
+
const total = Number(resp.headers.get('content-length') || 0);
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let got = 0;
|
|
49
|
+
const reader = resp.body.getReader();
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done)
|
|
53
|
+
break;
|
|
54
|
+
chunks.push(Buffer.from(value));
|
|
55
|
+
got += value.length;
|
|
56
|
+
if (total)
|
|
57
|
+
process.stderr.write(` ${((got / total) * 100).toFixed(0)}%\r`);
|
|
58
|
+
}
|
|
59
|
+
process.stderr.write('\n');
|
|
60
|
+
const buf = Buffer.concat(chunks);
|
|
61
|
+
if (buf.length < 10_000_000)
|
|
62
|
+
throw new Error('模型下载不完整,请重试');
|
|
63
|
+
await writeFile(dest, buf);
|
|
64
|
+
return dest;
|
|
65
|
+
}
|
|
66
|
+
/** 抠图主流程:输入图片路径 → 输出带 alpha 的 PNG Buffer */
|
|
67
|
+
export async function removeBackgroundLocal(inputPath, modelKey) {
|
|
68
|
+
// 动态加载:onnxruntime-node 是 optionalDependency,缺失时给安装指引而不是崩
|
|
69
|
+
let ort;
|
|
70
|
+
try {
|
|
71
|
+
ort = await import('onnxruntime-node');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error('onnxruntime-node 不可用。重装 CLI 即可补上:npm install -g museav-cli');
|
|
75
|
+
}
|
|
76
|
+
const sharp = await loadSharpOrThrow();
|
|
77
|
+
const modelFile = await ensureBgModel(modelKey);
|
|
78
|
+
const session = await ort.InferenceSession.create(modelFile);
|
|
79
|
+
// ── 预处理:EXIF 转正、去 alpha、RGB raw ──
|
|
80
|
+
const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
81
|
+
if (info.channels !== 3)
|
|
82
|
+
throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`);
|
|
83
|
+
// ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
|
|
84
|
+
const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
|
|
85
|
+
.resize(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
|
|
86
|
+
.raw()
|
|
87
|
+
.toBuffer();
|
|
88
|
+
const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE);
|
|
89
|
+
const N = INPUT_EDGE * INPUT_EDGE;
|
|
90
|
+
for (let i = 0; i < N; i++) {
|
|
91
|
+
f32[i] = (small[i * 3] / 255 - 0.5) / 0.5;
|
|
92
|
+
f32[N + i] = (small[i * 3 + 1] / 255 - 0.5) / 0.5;
|
|
93
|
+
f32[2 * N + i] = (small[i * 3 + 2] / 255 - 0.5) / 0.5;
|
|
94
|
+
}
|
|
95
|
+
const feeds = {};
|
|
96
|
+
feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, INPUT_EDGE, INPUT_EDGE]);
|
|
97
|
+
const results = await session.run(feeds);
|
|
98
|
+
const out = results[session.outputNames[0]];
|
|
99
|
+
const maskFlat = out.data;
|
|
100
|
+
if (maskFlat.length < N)
|
|
101
|
+
throw new Error(`模型输出尺寸异常(${maskFlat.length})`);
|
|
102
|
+
// ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
|
|
103
|
+
let lo = Infinity;
|
|
104
|
+
let hi = -Infinity;
|
|
105
|
+
for (let i = 0; i < N; i++) {
|
|
106
|
+
const v = maskFlat[i];
|
|
107
|
+
if (v < lo)
|
|
108
|
+
lo = v;
|
|
109
|
+
if (v > hi)
|
|
110
|
+
hi = v;
|
|
111
|
+
}
|
|
112
|
+
const range = hi - lo || 1;
|
|
113
|
+
const mask8 = Buffer.alloc(N);
|
|
114
|
+
for (let i = 0; i < N; i++)
|
|
115
|
+
mask8[i] = Math.round(((maskFlat[i] - lo) / range) * 255);
|
|
116
|
+
const maskFull = await sharp(mask8, { raw: { width: INPUT_EDGE, height: INPUT_EDGE, channels: 1 } })
|
|
117
|
+
.resize(info.width, info.height, { fit: 'fill' })
|
|
118
|
+
.raw()
|
|
119
|
+
.toBuffer();
|
|
120
|
+
// ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
|
|
121
|
+
const w = info.width;
|
|
122
|
+
const h = info.height;
|
|
123
|
+
const rgba = Buffer.alloc(w * h * 4);
|
|
124
|
+
for (let i = 0; i < w * h; i++) {
|
|
125
|
+
rgba[i * 4] = rgb[i * 3];
|
|
126
|
+
rgba[i * 4 + 1] = rgb[i * 3 + 1];
|
|
127
|
+
rgba[i * 4 + 2] = rgb[i * 3 + 2];
|
|
128
|
+
rgba[i * 4 + 3] = maskFull[i];
|
|
129
|
+
}
|
|
130
|
+
return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer();
|
|
131
|
+
}
|
|
132
|
+
async function loadSharpOrThrow() {
|
|
133
|
+
try {
|
|
134
|
+
const m = await import('sharp');
|
|
135
|
+
const sharp = m.default ?? m;
|
|
136
|
+
// 造 1px 图跑通全链路:native binding 坏了在第一次真用时才炸,这里提前暴露
|
|
137
|
+
await sharp({ create: { width: 1, height: 1, channels: 3, background: '#000' } }).raw().toBuffer();
|
|
138
|
+
return sharp;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli');
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/local-vision.js
CHANGED
|
@@ -16,6 +16,14 @@ function ollamaHost() {
|
|
|
16
16
|
host = `http://${host}`;
|
|
17
17
|
return host.replace(/\/+$/, '');
|
|
18
18
|
}
|
|
19
|
+
// 各系统启动 Ollama 的正确姿势不同,提示语跟着平台走(Windows 没有 brew)
|
|
20
|
+
function ollamaStartHint() {
|
|
21
|
+
if (process.platform === 'win32')
|
|
22
|
+
return '启动 Ollama 应用(开始菜单 / Ollama.exe),或命令行运行 ollama serve';
|
|
23
|
+
if (process.platform === 'darwin')
|
|
24
|
+
return 'brew services start ollama,或 ollama serve';
|
|
25
|
+
return 'systemctl --user start ollama,或 ollama serve';
|
|
26
|
+
}
|
|
19
27
|
/** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
|
|
20
28
|
export async function checkLocalVlm() {
|
|
21
29
|
const host = ollamaHost();
|
|
@@ -32,7 +40,7 @@ export async function checkLocalVlm() {
|
|
|
32
40
|
return { running: true, modelPresent: true, host };
|
|
33
41
|
}
|
|
34
42
|
catch {
|
|
35
|
-
return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}
|
|
43
|
+
return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),${ollamaStartHint()}` };
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
/** SCULPT 系统提示词 —— 从中台 reverse-template.js 移植。本地路只做纯读图,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "museav-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"sharp": "^0.35.3"
|
|
56
|
+
"sharp": "^0.35.3",
|
|
57
|
+
"onnxruntime-node": "^1.27.0"
|
|
57
58
|
}
|
|
58
59
|
}
|
package/src/client.ts
CHANGED
|
@@ -80,6 +80,32 @@ export interface GenerateOptions {
|
|
|
80
80
|
* · 只派给声明了该能力的上游;一家都没有时返回 400 说明原因,**不会静默出白底图**
|
|
81
81
|
*/
|
|
82
82
|
background?: 'transparent' | 'opaque'
|
|
83
|
+
/** 项目归档:生成结果挂到该工作区(中台仅账户身份收,租户身份忽略) */
|
|
84
|
+
workspace_id?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 工作区(项目):平台账户下的项目容器,素材库挂在它上面(GET/POST /api/workspaces) */
|
|
88
|
+
export interface Workspace {
|
|
89
|
+
id: string
|
|
90
|
+
name: string
|
|
91
|
+
brand?: string | null
|
|
92
|
+
description?: string | null
|
|
93
|
+
/** 该项目累计提交 / 完成的生成数(列表接口附带的统计) */
|
|
94
|
+
gen_total?: number
|
|
95
|
+
gen_done?: number
|
|
96
|
+
created_at?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 工作区素材(GET/POST /api/workspace-assets):项目素材库的一条记录 */
|
|
100
|
+
export interface WorkspaceAsset {
|
|
101
|
+
id: string
|
|
102
|
+
workspace_id: string
|
|
103
|
+
media_type: 'image' | 'video' | 'audio'
|
|
104
|
+
cdn_url: string
|
|
105
|
+
name: string | null
|
|
106
|
+
tags: string[]
|
|
107
|
+
size_bytes?: number | null
|
|
108
|
+
created_at?: string
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
/** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
|
|
@@ -412,6 +438,8 @@ export class StudioClient {
|
|
|
412
438
|
if (opts.reference_images?.length) body.reference_images = opts.reference_images
|
|
413
439
|
if (opts.quality) body.quality = opts.quality
|
|
414
440
|
if (opts.background) body.background = opts.background
|
|
441
|
+
// 项目归档:中台只对账户身份收 workspace_id(租户身份忽略),CLI 不做二次校验
|
|
442
|
+
if (opts.workspace_id) body.workspace_id = opts.workspace_id
|
|
415
443
|
const r = await this.request('generate', {
|
|
416
444
|
method: 'POST',
|
|
417
445
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -426,6 +454,49 @@ export class StudioClient {
|
|
|
426
454
|
return r
|
|
427
455
|
}
|
|
428
456
|
|
|
457
|
+
// ── 工作区(项目)与项目素材库 ──
|
|
458
|
+
// 平台 → 账户 → 工作区三层归属;素材挂工作区,换业务换工作区,互不污染。
|
|
459
|
+
|
|
460
|
+
/** 列当前账户的工作区(含生成统计) */
|
|
461
|
+
async workspaces(): Promise<Workspace[]> {
|
|
462
|
+
return this.request('workspaces')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 新建工作区(最多 5 个,超了服务端会 400) */
|
|
466
|
+
async createWorkspace(name: string): Promise<Workspace> {
|
|
467
|
+
return this.request('workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 列某工作区的素材库 */
|
|
471
|
+
async workspaceAssets(workspaceId: string): Promise<WorkspaceAsset[]> {
|
|
472
|
+
return this.request(`workspace-assets?workspace_id=${encodeURIComponent(workspaceId)}`)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 上传素材进工作区素材库。素材是母版,**不做视觉压缩**(fileForm 那套压缩是给模型看的) */
|
|
476
|
+
async addWorkspaceAsset(input: {
|
|
477
|
+
file: string
|
|
478
|
+
workspaceId: string
|
|
479
|
+
name?: string
|
|
480
|
+
tags?: string[]
|
|
481
|
+
}): Promise<WorkspaceAsset> {
|
|
482
|
+
const blob = new Blob([new Uint8Array(readFileSync(input.file))])
|
|
483
|
+
const fd = new FormData()
|
|
484
|
+
fd.append('file', blob, basename(input.file))
|
|
485
|
+
fd.append('workspace_id', input.workspaceId)
|
|
486
|
+
if (input.name) fd.append('name', input.name)
|
|
487
|
+
for (const t of input.tags || []) fd.append('tags', t)
|
|
488
|
+
return this.request('workspace-assets', { method: 'POST', body: fd })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 删除素材(硬删:R2 对象 + 记录) */
|
|
492
|
+
async deleteWorkspaceAsset(id: string): Promise<void> {
|
|
493
|
+
await this.request('workspace-assets', {
|
|
494
|
+
method: 'DELETE',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify({ id }),
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
429
500
|
/**
|
|
430
501
|
* 列出当前身份名下的出图工作流(不传 id,走同一个 jobs 端点的集合语义)。
|
|
431
502
|
* 范围由鉴权凭证决定:个人 token 只看得到自己出的图;租户 apiKey 看得到自己业务下的全部记录。
|
|
@@ -474,19 +545,22 @@ export class StudioClient {
|
|
|
474
545
|
duration?: number
|
|
475
546
|
/** 图生视频:首帧/参考图 URL(中台内部自动上传垫图后拿到 URL 再传这里) */
|
|
476
547
|
image_url?: string
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
548
|
+
template_id?: string
|
|
549
|
+
input?: string | Record<string, string>
|
|
550
|
+
callback_url?: string
|
|
551
|
+
/** 项目归档(账户身份才生效) */
|
|
552
|
+
workspace_id?: string
|
|
553
|
+
}): Promise<{ jobId: string; upstreamTaskId?: string }> {
|
|
554
|
+
const body: Record<string, unknown> = {}
|
|
555
|
+
if (opts.prompt) body.prompt = opts.prompt
|
|
556
|
+
if (opts.model) body.model = opts.model
|
|
557
|
+
if (opts.ratio) body.ratio = opts.ratio
|
|
558
|
+
if (opts.duration != null) body.duration = opts.duration
|
|
559
|
+
if (opts.image_url) body.image_url = opts.image_url
|
|
560
|
+
if (opts.template_id) body.template_id = opts.template_id
|
|
561
|
+
if (opts.input) body.input = opts.input
|
|
562
|
+
if (opts.callback_url) body.callback_url = opts.callback_url
|
|
563
|
+
if (opts.workspace_id) body.workspace_id = opts.workspace_id
|
|
490
564
|
const r = await this.request('videos', {
|
|
491
565
|
method: 'POST',
|
|
492
566
|
headers: { 'Content-Type': 'application/json' },
|
package/src/commands/gen.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
2
|
import type { StudioClient } from '../client.js'
|
|
3
|
+
import { resolveWorkspace } from './projects.js'
|
|
3
4
|
|
|
4
5
|
/** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
|
|
5
6
|
const MAX_REFS = 5
|
|
@@ -15,6 +16,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
15
16
|
quality?: string
|
|
16
17
|
ref?: string[] // 可重复:--ref a.jpg --ref b.jpg,顺序即「图片1、图片2…」
|
|
17
18
|
transparent?: boolean // 透明背景 PNG;能不能做由中台按上游能力判定,做不了会明确报错
|
|
19
|
+
project?: string // 工作区 id|名:生成结果归档进该项目(账户身份才生效)
|
|
18
20
|
// 视频
|
|
19
21
|
video?: boolean
|
|
20
22
|
duration?: number
|
|
@@ -63,6 +65,13 @@ export async function gen(client: StudioClient, opts: {
|
|
|
63
65
|
if (refPaths.length) {
|
|
64
66
|
const urls: string[] = []
|
|
65
67
|
for (const [i, refPath] of refPaths.entries()) {
|
|
68
|
+
// http(s) 直链(典型来源:museav projects assets 的素材库 URL)本身就是
|
|
69
|
+
// 中台 CDN 地址,直接当参考图用,不走上传
|
|
70
|
+
if (/^https?:\/\//.test(refPath)) {
|
|
71
|
+
urls.push(refPath)
|
|
72
|
+
process.stderr.write(` 图片${i + 1} 直链: ${refPath}\n`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
66
75
|
process.stderr.write(`上传垫图 [图片${i + 1}] ${refPath} ...\n`)
|
|
67
76
|
const up = await client.uploadRef(refPath)
|
|
68
77
|
urls.push(up.url)
|
|
@@ -72,6 +81,9 @@ export async function gen(client: StudioClient, opts: {
|
|
|
72
81
|
referenceImages = urls.length > 1 ? urls : undefined
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
// 项目归档:--project 解析成 workspace_id(名字/ id 都行),租户身份时中台会忽略
|
|
85
|
+
const workspaceId = opts.project ? (await resolveWorkspace(client, opts.project)).id : undefined
|
|
86
|
+
|
|
75
87
|
// ── 视频模式:走 /api/videos 独立链路 ──
|
|
76
88
|
if (opts.video) {
|
|
77
89
|
if (opts.quality) throw new Error('--quality 仅图片出图支持')
|
|
@@ -87,6 +99,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
87
99
|
image_url: referenceImage,
|
|
88
100
|
template_id: opts.template,
|
|
89
101
|
input: templateFields,
|
|
102
|
+
workspace_id: workspaceId,
|
|
90
103
|
})
|
|
91
104
|
process.stderr.write(`视频任务已提交: ${jobId}\n生成中(视频通常 1-5 分钟)...\n`)
|
|
92
105
|
const result = await client.waitVideo(jobId, (status) => {
|
|
@@ -123,6 +136,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
123
136
|
// 开关 → 枚举:CLI 这层用布尔开关最顺手,中台契约是 background: transparent|opaque
|
|
124
137
|
// (跟上游 gpt-image 的参数同名同值)。不传就不发,行为跟以前完全一样。
|
|
125
138
|
background: opts.transparent ? 'transparent' : undefined,
|
|
139
|
+
workspace_id: workspaceId,
|
|
126
140
|
},
|
|
127
141
|
(status) => {
|
|
128
142
|
if (status === 'processing') process.stderr.write('生成中...\r')
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** museav compress / remove-bg —— 本地图像工具箱。
|
|
2
|
+
* 纯本地、免登录、不碰中台;stdout 只出产物路径,统计与进度打 stderr。
|
|
3
|
+
* 代码零平台假设(路径全走 node:path/os,无 shell 展开、无 Unix-only 命令),macOS / Windows 通用。 */
|
|
4
|
+
import { stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
6
|
+
import { removeBackgroundLocal, BG_MODELS, type BgModelKey } from '../local-bg.js'
|
|
7
|
+
|
|
8
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
return (await stat(path)).isFile()
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fmtBytes(n: number): string {
|
|
17
|
+
return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 默认输出路径:同目录 <名字>-<后缀>.<新扩展名>。绝不覆写输入文件 */
|
|
21
|
+
export function defaultOut(input: string, suffix: string, newExt?: string): string {
|
|
22
|
+
const ext = newExt || extname(input).slice(1) || 'png'
|
|
23
|
+
return join(dirname(input), `${basename(input, extname(input))}-${suffix}.${ext}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CompressOpts {
|
|
27
|
+
out?: string
|
|
28
|
+
maxEdge?: string
|
|
29
|
+
quality?: string
|
|
30
|
+
format?: string
|
|
31
|
+
overwrite?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function compressCmd(input: string, opts: CompressOpts): Promise<void> {
|
|
35
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
36
|
+
|
|
37
|
+
let sharp: any
|
|
38
|
+
try {
|
|
39
|
+
const m = await import('sharp')
|
|
40
|
+
sharp = (m as any).default ?? m
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error('sharp 不可用(压缩依赖它)。重装 CLI 即可补上:npm install -g museav-cli')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const format = (opts.format || '').toLowerCase()
|
|
46
|
+
if (format && !['jpg', 'png', 'webp'].includes(format)) throw new Error('--format 只支持 jpg / png / webp')
|
|
47
|
+
const quality = opts.quality ? Number(opts.quality) : 82
|
|
48
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw new Error('--quality 必须是 1-100')
|
|
49
|
+
const maxEdge = opts.maxEdge ? Number(opts.maxEdge) : 0
|
|
50
|
+
if (opts.maxEdge && (!Number.isFinite(maxEdge) || maxEdge < 16)) throw new Error('--max-edge 至少 16px')
|
|
51
|
+
|
|
52
|
+
const meta = await sharp(input).metadata()
|
|
53
|
+
// 不指定 --format 时保持原格式;不在三之列的(tiff/bmp/heic…)统一转 jpg
|
|
54
|
+
const srcFormat = String(meta.format || '')
|
|
55
|
+
const target = format || (srcFormat === 'png' ? 'png' : srcFormat === 'webp' ? 'webp' : 'jpg')
|
|
56
|
+
|
|
57
|
+
let pipeline = sharp(input).rotate() // 尊重 EXIF 方向
|
|
58
|
+
if (maxEdge) pipeline = pipeline.resize({ width: maxEdge, height: maxEdge, fit: 'inside' })
|
|
59
|
+
if (target === 'jpg') pipeline = pipeline.jpeg({ quality, mozjpeg: true })
|
|
60
|
+
else if (target === 'webp') pipeline = pipeline.webp({ quality })
|
|
61
|
+
else pipeline = pipeline.png({ compressionLevel: 9 })
|
|
62
|
+
|
|
63
|
+
const outPath = opts.out || defaultOut(input, 'min', target === 'jpg' && srcFormat === 'jpeg' ? 'jpg' : target)
|
|
64
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
65
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
66
|
+
}
|
|
67
|
+
const buf = await pipeline.toBuffer()
|
|
68
|
+
await writeFile(outPath, buf)
|
|
69
|
+
|
|
70
|
+
const before = (await stat(input)).size
|
|
71
|
+
process.stderr.write(`✅ ${fmtBytes(before)} → ${fmtBytes(buf.length)}(省 ${Math.max(0, Math.round((1 - buf.length / before) * 100))}%,${target.toUpperCase()})\n`)
|
|
72
|
+
console.log(outPath)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RemoveBgOpts {
|
|
76
|
+
out?: string
|
|
77
|
+
model?: string
|
|
78
|
+
overwrite?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void> {
|
|
82
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
83
|
+
const modelKey = (opts.model || 'isnet') as BgModelKey
|
|
84
|
+
if (!(modelKey in BG_MODELS)) throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`)
|
|
85
|
+
|
|
86
|
+
const start = Date.now()
|
|
87
|
+
const png = await removeBackgroundLocal(input, modelKey)
|
|
88
|
+
const outPath = opts.out || defaultOut(input, 'nobg', 'png')
|
|
89
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
90
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
91
|
+
}
|
|
92
|
+
await writeFile(outPath, png)
|
|
93
|
+
process.stderr.write(`✅ 抠图完成(${BG_MODELS[modelKey].label},用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
|
|
94
|
+
console.log(outPath)
|
|
95
|
+
}
|
package/src/commands/jobs.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 不需要额外传租户/用户 id——你拿的是谁的凭证,就是谁的数据。
|
|
6
6
|
*/
|
|
7
7
|
import type { StudioClient, Job } from '../client.js'
|
|
8
|
+
import { resolveWorkspace } from './projects.js'
|
|
8
9
|
|
|
9
10
|
const STATUS_MARK: Record<Job['status'], string> = {
|
|
10
11
|
pending: '⏳',
|
|
@@ -13,9 +14,14 @@ const STATUS_MARK: Record<Job['status'], string> = {
|
|
|
13
14
|
failed: '❌',
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status'] }): Promise<void> {
|
|
17
|
+
export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status']; project?: string }): Promise<void> {
|
|
17
18
|
const limit = opts.limit ? Number(opts.limit) : 20
|
|
18
|
-
|
|
19
|
+
let list = await client.listJobs({ limit, status: opts.status })
|
|
20
|
+
// --project 客户端过滤:服务端 jobs 不认 workspace 参数,在最近 50 条内筛
|
|
21
|
+
if (opts.project) {
|
|
22
|
+
const ws = await resolveWorkspace(client, opts.project)
|
|
23
|
+
list = list.filter((j) => (j as unknown as { workspace_id?: string | null }).workspace_id === ws.id)
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
if (!list.length) {
|
|
21
27
|
process.stderr.write('没有找到工作流记录\n')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** museav projects —— 工作区(项目)与项目素材库。
|
|
2
|
+
* 层级:平台 → 账户 → 工作区;素材挂工作区,业务隔离互不污染。
|
|
3
|
+
* 「人像库的工作区出模特图、产品库的工作区出电商图」的载体就是这里。 */
|
|
4
|
+
import type { StudioClient, Workspace } from '../client.js'
|
|
5
|
+
|
|
6
|
+
/** --project <id|名> 的解析:id 精确命中,否则按名称匹配;找不到/歧义时把可选项列出来 */
|
|
7
|
+
export async function resolveWorkspace(client: StudioClient, idOrName: string): Promise<Workspace> {
|
|
8
|
+
const list = await client.workspaces()
|
|
9
|
+
const key = String(idOrName || '').trim()
|
|
10
|
+
if (!key) throw new Error('缺少 --project(工作区 id 或名称)')
|
|
11
|
+
const byId = list.find((w) => w.id === key)
|
|
12
|
+
if (byId) return byId
|
|
13
|
+
const byName = list.filter((w) => w.name === key)
|
|
14
|
+
if (byName.length === 1) return byName[0]
|
|
15
|
+
if (byName.length > 1) throw new Error(`重名工作区「${key}」,请用 id 指定:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}`)
|
|
16
|
+
throw new Error(`没有工作区「${key}」。现有:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}\n(museav projects create --name 可新建)`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function projects(client: StudioClient): Promise<void> {
|
|
20
|
+
const list = await client.workspaces()
|
|
21
|
+
if (!list.length) {
|
|
22
|
+
process.stderr.write('还没有工作区(museav projects create --name 新建)\n')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
process.stderr.write(`工作区(${list.length} 个):\n`)
|
|
26
|
+
for (const w of list) {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
process.stderr.write(`\n素材库: museav projects assets --project <id|名>\n出图归档: museav gen --project <id|名> ...\n`)
|
|
32
|
+
// stdout 只出 id,便于脚本解析
|
|
33
|
+
console.log(list.map((w) => w.id).join('\n'))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createProject(client: StudioClient, opts: { name: string }): Promise<void> {
|
|
37
|
+
const name = (opts.name || '').trim()
|
|
38
|
+
if (!name) throw new Error('--name 必填')
|
|
39
|
+
const row = await client.createWorkspace(name)
|
|
40
|
+
process.stderr.write(`✅ 工作区已建:${row.id} ${row.name}\n`)
|
|
41
|
+
process.stderr.write(`传素材: museav projects assets add <file> --project ${row.id}\n`)
|
|
42
|
+
console.log(row.id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listAssets(client: StudioClient, opts: { project?: string }): Promise<void> {
|
|
46
|
+
const ws = await resolveWorkspace(client, opts.project || '')
|
|
47
|
+
const assets = await client.workspaceAssets(ws.id)
|
|
48
|
+
process.stderr.write(`「${ws.name}」素材库(${assets.length} 条):\n`)
|
|
49
|
+
for (const a of assets) {
|
|
50
|
+
const tag = a.tags?.length ? `[${a.tags.join(',')}]` : ''
|
|
51
|
+
process.stderr.write(` ${a.id} ${(a.name || '(未命名)').padEnd(16)} ${a.media_type.padEnd(5)} ${tag}\n`)
|
|
52
|
+
process.stderr.write(` ${a.cdn_url}\n`)
|
|
53
|
+
}
|
|
54
|
+
process.stderr.write(`\n垫图出图: museav gen --project ${ws.id} --ref <素材URL> --prompt '...'\n`)
|
|
55
|
+
// stdout:id<TAB>url 每行一条,agent 拿去直接当 --ref 用
|
|
56
|
+
console.log(assets.map((a) => `${a.id}\t${a.cdn_url}`).join('\n'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function addAsset(
|
|
60
|
+
client: StudioClient,
|
|
61
|
+
file: string,
|
|
62
|
+
opts: { project?: string; name?: string; tag?: string[] },
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const ws = await resolveWorkspace(client, opts.project || '')
|
|
65
|
+
const row = await client.addWorkspaceAsset({
|
|
66
|
+
file,
|
|
67
|
+
workspaceId: ws.id,
|
|
68
|
+
name: opts.name,
|
|
69
|
+
tags: opts.tag || [],
|
|
70
|
+
})
|
|
71
|
+
process.stderr.write(`✅ 已入「${ws.name}」素材库:${row.name || '(未命名)'} ${row.media_type}\n`)
|
|
72
|
+
process.stderr.write(`${row.cdn_url}\n`)
|
|
73
|
+
console.log(row.cdn_url)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function removeAsset(client: StudioClient, opts: { id: string }): Promise<void> {
|
|
77
|
+
await client.deleteWorkspaceAsset(opts.id)
|
|
78
|
+
process.stderr.write(`✅ 素材已删:${opts.id}\n`)
|
|
79
|
+
console.log(opts.id)
|
|
80
|
+
}
|