museav-cli 2.9.0 → 2.10.2
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/CHANGELOG.md +56 -0
- package/README.md +33 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +32 -0
- package/dist/commands/feedback.d.ts +5 -0
- package/dist/commands/feedback.js +67 -0
- package/dist/commands/slideshow.d.ts +11 -2
- package/dist/commands/slideshow.js +115 -15
- package/dist/index.js +25 -6
- package/dist/local-slideshow.d.ts +49 -6
- package/dist/local-slideshow.js +166 -57
- package/dist/slideshow-presets.d.ts +15 -0
- package/dist/slideshow-presets.js +29 -0
- package/package.json +1 -1
- package/src/client.ts +40 -0
- package/src/commands/feedback.ts +75 -0
- package/src/commands/slideshow.ts +123 -16
- package/src/index.ts +28 -6
- package/src/local-slideshow.ts +213 -65
- package/src/slideshow-presets.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.10.2 (2026-08-28)
|
|
4
|
+
|
|
5
|
+
### 修复:排版模板的图层顺序失效,图片会盖住它下面该有的文字
|
|
6
|
+
|
|
7
|
+
`slot` / `image` 图层被统一提到最后合成,导致**所有图片永远压在所有矢量层之上**,
|
|
8
|
+
DSL 里 `layers` 的先后顺序当场失效。「图片铺满画面 + 文字压在上面」这类版式
|
|
9
|
+
渲染出来是文字被图片盖掉。
|
|
10
|
+
|
|
11
|
+
内置的粉青版式没受影响(那里图与文字不重叠),所以 2.10.1 发出去时没被发现 ——
|
|
12
|
+
是拿一个结构完全不同的版式(满屏叠字)实测才暴露的。
|
|
13
|
+
|
|
14
|
+
改成按 `layers` 顺序分段渲染:连续的矢量层攒成一段 SVG,遇到图片层就断开,
|
|
15
|
+
按原顺序逐段叠加。
|
|
16
|
+
|
|
17
|
+
## 2.10.1 (2026-08-28)
|
|
18
|
+
|
|
19
|
+
> 这一版同时发布了另一条线的 `feedback` 命令(命令行提 bug/需求),
|
|
20
|
+
> 下面记的是 slideshow 这条线的改动。
|
|
21
|
+
|
|
22
|
+
### `slideshow` 的版面改由**排版模板**描述,模板库在中台
|
|
23
|
+
|
|
24
|
+
2.9.0 的版面写死在代码里,改一个颜色都得发新版本。现在版面是一份**图层 DSL**:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
museav slideshow-layouts # 看有哪些版式(内置 + 中台)
|
|
28
|
+
museav slideshow ./图 --layout <slug> # 用中台的模板
|
|
29
|
+
museav slideshow ./图 --layout-file my.json # 用本地 JSON(调模板时免登录)
|
|
30
|
+
museav slideshow-layouts create my.json --name 我的版式 --slug my-style
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
DSL 支持 `rect` / `ellipse` / `text` / `slot` / `image` 五种图层,`text` 和 `slot` 用
|
|
34
|
+
`bind` 绑定内容(title / subtitle / caption / footer / image),所以同一个模板能套任意素材。
|
|
35
|
+
`image` 层可以写 `sticker://<id>` 直接引用中台贴图库里的透明装饰图。
|
|
36
|
+
|
|
37
|
+
**免登录这条路原样保留**:不给 `--layout` 就用内置版式,全程不碰网络。这是 slideshow
|
|
38
|
+
秒级出片、零成本的立身之本,接了模板库也不能丢。
|
|
39
|
+
|
|
40
|
+
模板结构的真源在中台 `shared/slideshow-layout.js`,**校验也只在中台写入时做**。
|
|
41
|
+
CLI 是消费方:遇到不认识的图层类型跳过该层并提示升级,不报错中断 ——
|
|
42
|
+
两侧都校验的话,老 CLI 会把新写的合法模板判为非法,那才是真坏掉。
|
|
43
|
+
|
|
44
|
+
### 行为变化:主图会比 2.9.0 略大(约 5%)
|
|
45
|
+
|
|
46
|
+
`slot` 的 `box` 语义是**矩形框 + contain**(同 CSS `object-fit`),而 2.9.0 是把主图区
|
|
47
|
+
当成正方形边长。差别在高瘦的图上:一张 219×229 的贴纸放进 518×701 的框,
|
|
48
|
+
旧算法出 495×518(受高度限制),新算法出 518×542。
|
|
49
|
+
|
|
50
|
+
换成框语义是刻意的 —— 框多大图就能占多大才符合直觉,也是「主图偏小」那个老问题的根治。
|
|
51
|
+
实测两版逐像素比对:主图区差异 12%(就是这 5% 的尺寸差),其余区域 0.1~2.4%
|
|
52
|
+
(亚像素位移的抗锯齿噪声)。版面结构完全一致。
|
|
53
|
+
|
|
54
|
+
### 已知缺口
|
|
55
|
+
|
|
56
|
+
CLI 内置的 `sticker-pink-mint` 与中台平台预置的同名模板是同一份 DSL,靠人工同步 ——
|
|
57
|
+
本仓库还没有测试框架,这条一致性目前没有机器检查。
|
|
58
|
+
|
|
3
59
|
## 2.9.0 (2026-08-28)
|
|
4
60
|
|
|
5
61
|
### 新增:`slideshow` —— 一组图 + 文案 + 配乐 → 竖版短视频(免登录)
|
package/README.md
CHANGED
|
@@ -463,6 +463,37 @@ museav slideshow ./图片目录 --captions 文案.txt --title "作品集"
|
|
|
463
463
|
|
|
464
464
|
默认 1080×1920 / 30fps / H.264+AAC,每张停 2.5 秒;`--size` 换尺寸、`--theme dark` 换深色底。
|
|
465
465
|
|
|
466
|
+
#### 排版模板:版面不写死在代码里
|
|
467
|
+
|
|
468
|
+
版面由一份**图层 DSL** 描述,模板库在中台,改版式不用等 CLI 发新版:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
museav slideshow-layouts # 看有哪些版式(内置 + 中台)
|
|
472
|
+
museav slideshow ./图 --layout <slug> # 用中台模板
|
|
473
|
+
museav slideshow ./图 --layout-file my.json # 用本地 JSON(调模板时用,免登录)
|
|
474
|
+
museav slideshow-layouts create my.json --name 我的版式 --slug my-style
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
DSL 支持 `rect` / `ellipse` / `text` / `slot` / `image` 五种图层。`text` 和 `slot` 用 `bind`
|
|
478
|
+
绑定内容(`title` / `subtitle` / `caption` / `footer` / `image`),所以同一个模板能套任意一套素材;
|
|
479
|
+
`image` 层可以写 `sticker://<id>` 引用中台贴图库里的透明装饰图。
|
|
480
|
+
|
|
481
|
+
```json
|
|
482
|
+
{
|
|
483
|
+
"canvas": { "w": 1080, "h": 1920, "bg": "#ffffff" },
|
|
484
|
+
"layers": [
|
|
485
|
+
{ "type": "ellipse", "cx": 190, "cy": -131, "rx": 510, "ry": 430, "fill": "#ffd6e2" },
|
|
486
|
+
{ "type": "text", "bind": "title", "x": 540, "y": 394, "size": 84, "align": "center", "maxWidth": 0.86 },
|
|
487
|
+
{ "type": "slot", "bind": "image", "box": [281, 660, 518, 701], "fit": "contain", "trim": true }
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
> **不给 `--layout` 就完全不碰网络**,用内置版式出片。免登录是这个命令的立身之本,接了模板库也没丢。
|
|
493
|
+
>
|
|
494
|
+
> 模板结构的真源与校验都在中台。CLI 是消费方,遇到不认识的图层类型**跳过该层并提示升级**,
|
|
495
|
+
> 不报错中断 —— 老 CLI 碰上新层类型该少画一层,不是彻底出不了片。
|
|
496
|
+
|
|
466
497
|
三个不写出来就会踩的点,已经在实现里处理掉了:
|
|
467
498
|
|
|
468
499
|
- **小图会被显式放大**。240×240 的贴纸直接贴到 1080 宽的画布上只占两成宽,画面空得离谱。
|
|
@@ -596,6 +627,8 @@ console.log(r.sculpt.light) // 光影分析
|
|
|
596
627
|
| `upscale <file>` | 本地超分放大,2M 图变 10M+(免登录,Real-ESRGAN GPU,`--scale 2/3/4`) | 产物路径 |
|
|
597
628
|
| `remove-watermark <file>` | 本地去水印(免登录,自动定位 + LaMa 修复,`--mask` 手工兜底) | 产物路径 |
|
|
598
629
|
| `slideshow <图片或目录...>` | 图集转竖版短视频(免登录,带标题/逐张文案/配乐,需 ffmpeg) | 产物路径 |
|
|
630
|
+
| `slideshow-layouts` | 查排版模板:内置版式(免登录)+ 中台模板(平台/租户/私有) | slug 列表 |
|
|
631
|
+
| `slideshow-layouts create <json>` | 从 JSON 新建排版模板(结构校验在服务端做) | 新模板 id |
|
|
599
632
|
| `projects` | 工作区(项目)列表:一账户多项目,各带自己的素材库 | 项目 id 列表 |
|
|
600
633
|
| `projects assets --project` | 项目素材库:ls / add / rm(垫图母版,人像库/产品库各管各的) | `id<TAB>url` 行 |
|
|
601
634
|
| `models` | 可用模型 | 模型名列表 |
|
package/dist/client.d.ts
CHANGED
|
@@ -343,6 +343,20 @@ export declare class StudioClient {
|
|
|
343
343
|
createSticker(filePath: string, name: string): Promise<any>;
|
|
344
344
|
/** 删除贴图素材 */
|
|
345
345
|
deleteSticker(id: string): Promise<void>;
|
|
346
|
+
/** 排版模板清单(图集视频用):平台共享 + 本租户 + 本人私有,服务端按身份过滤 */
|
|
347
|
+
slideshowLayouts(): Promise<any[]>;
|
|
348
|
+
/** 按 slug 取单个排版模板(CLI 的 --layout 走这条) */
|
|
349
|
+
slideshowLayout(slug: string): Promise<any>;
|
|
350
|
+
/** 新建排版模板。layout 的结构校验在服务端做,这里不预校验——
|
|
351
|
+
* 两侧都校验的话,老 CLI 会把新写的合法模板判为非法。 */
|
|
352
|
+
createSlideshowLayout(input: {
|
|
353
|
+
name: string;
|
|
354
|
+
slug: string;
|
|
355
|
+
layout: unknown;
|
|
356
|
+
description?: string;
|
|
357
|
+
category?: string;
|
|
358
|
+
sample_url?: string;
|
|
359
|
+
}): Promise<any>;
|
|
346
360
|
/** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
|
|
347
361
|
posterTemplates(): Promise<any[]>;
|
|
348
362
|
/** 上传版式模板(不压缩,保留封面底图原样) */
|
|
@@ -478,6 +492,23 @@ export declare class StudioClient {
|
|
|
478
492
|
models(): Promise<ModelOption[]>;
|
|
479
493
|
/** 查上游余额 */
|
|
480
494
|
balance(): Promise<Balance>;
|
|
495
|
+
/** 提交反馈(bug / 需求)。服务端按凭证归人:账户 Key 归账户,网页 JWT 归本人。 */
|
|
496
|
+
submitFeedback(input: {
|
|
497
|
+
type: 'bug' | '需求';
|
|
498
|
+
content: string;
|
|
499
|
+
images?: string[];
|
|
500
|
+
}): Promise<{
|
|
501
|
+
ok: boolean;
|
|
502
|
+
}>;
|
|
503
|
+
/** 拉我的反馈记录(个人账户 Key / JWT 看自己的,按时间倒序) */
|
|
504
|
+
listFeedback(): Promise<Array<{
|
|
505
|
+
id: string;
|
|
506
|
+
type: string;
|
|
507
|
+
content: string;
|
|
508
|
+
status?: string;
|
|
509
|
+
reply?: string;
|
|
510
|
+
created_at: string;
|
|
511
|
+
}>>;
|
|
481
512
|
}
|
|
482
513
|
/**
|
|
483
514
|
* 区分 imageToTemplate() 拿到的是异步回执还是结果本体。
|
package/dist/client.js
CHANGED
|
@@ -142,6 +142,25 @@ export class StudioClient {
|
|
|
142
142
|
async deleteSticker(id) {
|
|
143
143
|
await this.request(`stickers?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
144
144
|
}
|
|
145
|
+
/** 排版模板清单(图集视频用):平台共享 + 本租户 + 本人私有,服务端按身份过滤 */
|
|
146
|
+
async slideshowLayouts() {
|
|
147
|
+
const r = await this.request('slideshow-layouts');
|
|
148
|
+
return Array.isArray(r) ? r : [];
|
|
149
|
+
}
|
|
150
|
+
/** 按 slug 取单个排版模板(CLI 的 --layout 走这条) */
|
|
151
|
+
async slideshowLayout(slug) {
|
|
152
|
+
return this.request(`slideshow-layouts?slug=${encodeURIComponent(slug)}`);
|
|
153
|
+
}
|
|
154
|
+
/** 新建排版模板。layout 的结构校验在服务端做,这里不预校验——
|
|
155
|
+
* 两侧都校验的话,老 CLI 会把新写的合法模板判为非法。 */
|
|
156
|
+
async createSlideshowLayout(input) {
|
|
157
|
+
const r = await this.request('slideshow-layouts', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(input),
|
|
161
|
+
});
|
|
162
|
+
return r.layout ?? r;
|
|
163
|
+
}
|
|
145
164
|
/** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
|
|
146
165
|
async posterTemplates() {
|
|
147
166
|
const r = await this.request('poster-templates');
|
|
@@ -450,6 +469,19 @@ export class StudioClient {
|
|
|
450
469
|
async balance() {
|
|
451
470
|
return this.request('balance');
|
|
452
471
|
}
|
|
472
|
+
/** 提交反馈(bug / 需求)。服务端按凭证归人:账户 Key 归账户,网页 JWT 归本人。 */
|
|
473
|
+
async submitFeedback(input) {
|
|
474
|
+
return this.request('feedback', {
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers: { 'Content-Type': 'application/json' },
|
|
477
|
+
body: JSON.stringify({ type: input.type, content: input.content, images: input.images || [] }),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/** 拉我的反馈记录(个人账户 Key / JWT 看自己的,按时间倒序) */
|
|
481
|
+
async listFeedback() {
|
|
482
|
+
const r = await this.request('feedback');
|
|
483
|
+
return Array.isArray(r) ? r : [];
|
|
484
|
+
}
|
|
453
485
|
}
|
|
454
486
|
function sleep(ms) {
|
|
455
487
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav feedback —— 通过命令行提 bug / 需求,或查我的反馈记录。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:之前 CLI 没有 feedback,用户只能在网页后台提。命令行跑出问题(出图
|
|
5
|
+
* 失败、CLI 报错)时,最顺手的就是当下在终端补一条反馈,而不是切到浏览器。
|
|
6
|
+
*
|
|
7
|
+
* 身份:跟 CLI 其它命令一致,走 loadConfig 的凭证——
|
|
8
|
+
* - 个人 login token → /api/feedback 按本人落库
|
|
9
|
+
* - 账户 API Key(sk-studio-*,指向 accounts.api_key_hash)→ /api/feedback 自解析身份(2026-08 补)
|
|
10
|
+
* - 租户 API Key 会被服务端拒(反馈是个人行为,不该落到租户组织头上)→ 报错提示用个人身份
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* museav feedback "出图连续失败三次"
|
|
14
|
+
* museav feedback --type 需求 "想要批量出图后自动拼长图"
|
|
15
|
+
* museav feedback --type bug # 不带内容 → 交互式输入
|
|
16
|
+
* museav feedback --list # 看我的反馈记录
|
|
17
|
+
*/
|
|
18
|
+
import { createInterface } from 'node:readline/promises';
|
|
19
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
20
|
+
export async function feedback(getClient, content, opts = {}) {
|
|
21
|
+
if (opts.list) {
|
|
22
|
+
await listFeedback(getClient);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const type = opts.type === '需求' ? '需求' : 'bug';
|
|
26
|
+
let text = content?.trim() || '';
|
|
27
|
+
if (!text) {
|
|
28
|
+
// 交互式:没有在命令行给内容,就逐项问。这是给"想提但没想好格式"的人用的,
|
|
29
|
+
// 也给脚本调用了一个确定的形式(--type + 位置参数)。
|
|
30
|
+
const rl = createInterface({ input, output });
|
|
31
|
+
try {
|
|
32
|
+
const pick = await rl.question(`反馈类型?(bug / 需求,默认 ${type === '需求' ? '需求' : 'bug'}): `);
|
|
33
|
+
const picked = pick.trim();
|
|
34
|
+
const finalType = picked === '需求' ? '需求' : picked === 'bug' ? 'bug' : type;
|
|
35
|
+
const body = await rl.question(`描述(你遇到的 bug 或想要的功能): `);
|
|
36
|
+
text = body.trim();
|
|
37
|
+
if (text) {
|
|
38
|
+
process.stderr.write(`\n提交中...\n`);
|
|
39
|
+
await getClient().submitFeedback({ type: finalType, content: text });
|
|
40
|
+
process.stderr.write(`✅ 已提交${finalType === '需求' ? '需求' : 'bug'}反馈。可以在网页「意见反馈」页看到进度。\n`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
process.stderr.write(`未输入内容,已取消。\n`);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
process.stderr.write(`提交${type === '需求' ? '需求' : 'bug'}反馈...\n`);
|
|
52
|
+
await getClient().submitFeedback({ type, content: text });
|
|
53
|
+
process.stderr.write(`✅ 已提交${type === '需求' ? '需求' : 'bug'}反馈。可以在网页「意见反馈」页看到进度。\n`);
|
|
54
|
+
}
|
|
55
|
+
async function listFeedback(getClient) {
|
|
56
|
+
const rows = await getClient().listFeedback();
|
|
57
|
+
if (!rows.length) {
|
|
58
|
+
process.stderr.write(`还没有反馈记录。\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write(`我的反馈(${rows.length} 条):\n`);
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
const status = r.status === 'done' ? '已完成' : r.status === 'resolved' ? '已解决' : r.status === 'pending' ? '处理中' : (r.status || '处理中');
|
|
64
|
+
const reply = r.reply ? `\n 回复: ${r.reply}` : '';
|
|
65
|
+
process.stderr.write(`\n[${r.type}] ${status} ${r.created_at}\n ${r.content}${reply}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -8,8 +8,17 @@ export interface SlideshowCmdOpts {
|
|
|
8
8
|
music?: string;
|
|
9
9
|
sec?: string;
|
|
10
10
|
size?: string;
|
|
11
|
-
art?: string;
|
|
12
11
|
theme?: string;
|
|
13
|
-
|
|
12
|
+
layout?: string;
|
|
13
|
+
layoutFile?: string;
|
|
14
14
|
}
|
|
15
15
|
export declare function slideshowCmd(paths: string[], opts: SlideshowCmdOpts): Promise<void>;
|
|
16
|
+
/** museav slideshow-layouts —— 列出可用排版模板(内置 + 中台) */
|
|
17
|
+
export declare function slideshowLayoutsCmd(): Promise<void>;
|
|
18
|
+
/** museav slideshow-layouts create —— 从 JSON 文件建一个排版模板 */
|
|
19
|
+
export declare function createSlideshowLayoutCmd(file: string, opts: {
|
|
20
|
+
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
}): Promise<void>;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/** museav slideshow —— 一组图 + 文案 + 配乐 → 竖版短视频。
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 纯本地渲染、不上传任何图片;stdout 只出产物路径,进度与统计打 stderr。
|
|
3
|
+
*
|
|
4
|
+
* 版面来自排版模板:不给 --layout 用内置的(**完全免登录、不碰网络**),
|
|
5
|
+
* 给了就去中台 slideshow_layouts 拉。免登录这条路必须一直留着——
|
|
6
|
+
* 秒级出片、零成本是这个命令的立身之本,不能因为接了模板库就没了。 */
|
|
4
7
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
5
8
|
import { extname, isAbsolute, join, resolve } from 'node:path';
|
|
6
9
|
import { makeSlideshow, fileSize } from '../local-slideshow.js';
|
|
10
|
+
import { PRESETS, PRESET_LIGHT, PRESET_DARK } from '../slideshow-presets.js';
|
|
11
|
+
import { loadConfig } from '../config.js';
|
|
12
|
+
import { StudioClient } from '../client.js';
|
|
7
13
|
const IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
8
14
|
function fmtBytes(n) {
|
|
9
15
|
return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`;
|
|
@@ -34,6 +40,40 @@ async function collectImages(paths) {
|
|
|
34
40
|
}
|
|
35
41
|
return out;
|
|
36
42
|
}
|
|
43
|
+
/** 按需建 client:只有真要拉中台模板时才需要凭证,没有 --layout 就一路不碰网络 */
|
|
44
|
+
function studioClient() {
|
|
45
|
+
const cfg = loadConfig();
|
|
46
|
+
if (!cfg.token && !cfg.apiKey) {
|
|
47
|
+
throw new Error('拉中台排版模板需要登录:museav login(或配 apiKey)。\n不想登录就别传 --layout,内置版式免登录可用');
|
|
48
|
+
}
|
|
49
|
+
return new StudioClient({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, token: cfg.token });
|
|
50
|
+
}
|
|
51
|
+
async function loadLayout(opts) {
|
|
52
|
+
if (opts.layoutFile) {
|
|
53
|
+
const raw = await readFile(opts.layoutFile, 'utf8').catch(() => {
|
|
54
|
+
throw new Error(`排版模板文件读不到: ${opts.layoutFile}`);
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
return { layout: JSON.parse(raw), label: `文件 ${opts.layoutFile}` };
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
throw new Error(`排版模板不是合法 JSON: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (opts.layout) {
|
|
64
|
+
// 内置的同名模板优先命中,省一次网络往返;不在内置里才去中台拉
|
|
65
|
+
const builtin = PRESETS[opts.layout];
|
|
66
|
+
if (builtin)
|
|
67
|
+
return { layout: builtin, label: `内置 ${opts.layout}` };
|
|
68
|
+
const client = studioClient();
|
|
69
|
+
const row = await client.slideshowLayout(opts.layout);
|
|
70
|
+
if (!row?.layout)
|
|
71
|
+
throw new Error(`模板 ${opts.layout} 没有 layout 内容`);
|
|
72
|
+
return { layout: row.layout, label: `中台 ${row.name}(${row.slug})`, client };
|
|
73
|
+
}
|
|
74
|
+
const dark = (opts.theme || 'light') === 'dark';
|
|
75
|
+
return { layout: dark ? PRESET_DARK : PRESET_LIGHT, label: dark ? '内置深色' : '内置默认' };
|
|
76
|
+
}
|
|
37
77
|
export async function slideshowCmd(paths, opts) {
|
|
38
78
|
const images = await collectImages(paths);
|
|
39
79
|
let captions = opts.caption?.length ? [...opts.caption] : [];
|
|
@@ -46,11 +86,12 @@ export async function slideshowCmd(paths, opts) {
|
|
|
46
86
|
if (captions.length && captions.length < images.length) {
|
|
47
87
|
process.stderr.write(`⚠️ 文案 ${captions.length} 条少于图片 ${images.length} 张,后面几张不带文字\n`);
|
|
48
88
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
89
|
+
const { layout, label, client } = await loadLayout(opts);
|
|
90
|
+
const sec = opts.sec ? Number(opts.sec) : undefined;
|
|
91
|
+
if (opts.sec && (!Number.isFinite(sec) || sec <= 0))
|
|
51
92
|
throw new Error('--sec 必须是正数');
|
|
52
|
-
let width
|
|
53
|
-
let height
|
|
93
|
+
let width;
|
|
94
|
+
let height;
|
|
54
95
|
if (opts.size) {
|
|
55
96
|
const m = /^(\d+)[x×](\d+)$/i.exec(opts.size.trim());
|
|
56
97
|
if (!m)
|
|
@@ -58,25 +99,84 @@ export async function slideshowCmd(paths, opts) {
|
|
|
58
99
|
width = Number(m[1]);
|
|
59
100
|
height = Number(m[2]);
|
|
60
101
|
}
|
|
61
|
-
const artSize = opts.art ? Number(opts.art) : Math.round(width * 0.48);
|
|
62
|
-
if (!Number.isFinite(artSize) || artSize < 16)
|
|
63
|
-
throw new Error('--art 至少 16px');
|
|
64
|
-
const theme = (opts.theme || 'light');
|
|
65
|
-
if (!['light', 'dark'].includes(theme))
|
|
66
|
-
throw new Error('--theme 只支持 light / dark');
|
|
67
102
|
const out = opts.out ? resolve(opts.out) : resolve('slideshow.mp4');
|
|
68
|
-
const
|
|
69
|
-
|
|
103
|
+
const effSec = sec ?? layout.seconds ?? 2.5;
|
|
104
|
+
const w = width ?? layout.canvas.w;
|
|
105
|
+
const h = height ?? layout.canvas.h;
|
|
106
|
+
process.stderr.write(`${images.length} 张 × ${effSec}s ≈ ${(images.length * effSec).toFixed(0)}s · ${w}×${h} · 版式:${label}` +
|
|
70
107
|
(opts.title ? ` · 「${opts.title}」` : '') +
|
|
71
108
|
(opts.music ? ' · 带配乐' : ' · 无配乐') + '\n');
|
|
109
|
+
// 贴图只在模板真的引用了 sticker:// 时才去拉,且整轮只拉一次清单
|
|
110
|
+
let stickerUrls = null;
|
|
111
|
+
const resolveSticker = async (id) => {
|
|
112
|
+
if (!stickerUrls) {
|
|
113
|
+
const c = client ?? studioClient();
|
|
114
|
+
const rows = await c.stickers().catch(() => []);
|
|
115
|
+
stickerUrls = new Map(rows.filter((r) => r?.id && r?.url).map((r) => [r.id, r.url]));
|
|
116
|
+
}
|
|
117
|
+
return stickerUrls.get(id) ?? null;
|
|
118
|
+
};
|
|
72
119
|
const start = Date.now();
|
|
73
120
|
const r = await makeSlideshow({
|
|
74
|
-
images, out, width, height,
|
|
121
|
+
images, out, layout, width, height, seconds: sec,
|
|
75
122
|
title: opts.title, subtitle: opts.subtitle, footer: opts.footer,
|
|
76
123
|
captions: captions.length ? captions : undefined,
|
|
77
124
|
music: opts.music ? resolve(opts.music) : undefined,
|
|
125
|
+
resolveSticker,
|
|
126
|
+
onWarn: (m) => process.stderr.write(`⚠️ ${m}\n`),
|
|
78
127
|
});
|
|
79
128
|
process.stderr.write(`✅ 视频生成完成(${r.pages} 页,${r.seconds.toFixed(1)}s,` +
|
|
80
129
|
`${fmtBytes(await fileSize(out))},用时 ${((Date.now() - start) / 1000).toFixed(1)}s)\n`);
|
|
81
130
|
console.log(out);
|
|
82
131
|
}
|
|
132
|
+
/** museav slideshow-layouts —— 列出可用排版模板(内置 + 中台) */
|
|
133
|
+
export async function slideshowLayoutsCmd() {
|
|
134
|
+
process.stderr.write('内置版式(免登录可用):\n');
|
|
135
|
+
for (const slug of Object.keys(PRESETS)) {
|
|
136
|
+
process.stderr.write(` ${slug.padEnd(28)} ${PRESETS[slug].canvas.w}×${PRESETS[slug].canvas.h}\n`);
|
|
137
|
+
}
|
|
138
|
+
let rows = [];
|
|
139
|
+
try {
|
|
140
|
+
rows = await studioClient().slideshowLayouts();
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
// 没登录不算错:内置版式已经列出来了,这条命令仍然有用
|
|
144
|
+
process.stderr.write(`\n(中台模板未列出:${e.message.split('\n')[0]})\n`);
|
|
145
|
+
console.log(Object.keys(PRESETS).join('\n'));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (rows.length) {
|
|
149
|
+
process.stderr.write(`\n中台模板(${rows.length} 个):\n`);
|
|
150
|
+
for (const r of rows) {
|
|
151
|
+
const src = r.source === 'platform' ? '[平台]' : r.source === 'mine' ? '[租户]' : '[私有]';
|
|
152
|
+
const size = r.layout?.canvas ? `${r.layout.canvas.w}×${r.layout.canvas.h}` : '';
|
|
153
|
+
process.stderr.write(` ${String(r.slug).padEnd(28)} ${String(r.name).padEnd(20)} ${size.padEnd(10)} ${src}\n`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
process.stderr.write('\n中台还没有模板。用 museav slideshow-layouts create 建一个。\n');
|
|
158
|
+
}
|
|
159
|
+
process.stderr.write('\n出片: museav slideshow ./图片目录 --layout <slug>\n');
|
|
160
|
+
console.log([...Object.keys(PRESETS), ...rows.map((r) => r.slug)].join('\n'));
|
|
161
|
+
}
|
|
162
|
+
/** museav slideshow-layouts create —— 从 JSON 文件建一个排版模板 */
|
|
163
|
+
export async function createSlideshowLayoutCmd(file, opts) {
|
|
164
|
+
const raw = await readFile(file, 'utf8').catch(() => {
|
|
165
|
+
throw new Error(`读不到文件: ${file}`);
|
|
166
|
+
});
|
|
167
|
+
let layout;
|
|
168
|
+
try {
|
|
169
|
+
layout = JSON.parse(raw);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
throw new Error(`不是合法 JSON: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
const row = await studioClient().createSlideshowLayout({
|
|
175
|
+
name: opts.name, slug: opts.slug, layout,
|
|
176
|
+
description: opts.description, category: opts.category,
|
|
177
|
+
});
|
|
178
|
+
process.stderr.write(`✅ 排版模板已建:${row.name}(${row.slug})\n`);
|
|
179
|
+
process.stderr.write(`归属:${row.visibility === 'private' ? '仅本人可见' : row.tenant_id ? '本租户共享' : '平台共享'}\n`);
|
|
180
|
+
process.stderr.write(`\n出片: museav slideshow ./图片目录 --layout ${row.slug}\n`);
|
|
181
|
+
console.log(row.id);
|
|
182
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { printWelcome } from './commands/welcome.js';
|
|
|
16
16
|
import { gen } from './commands/gen.js';
|
|
17
17
|
import { reverse } from './commands/reverse.js';
|
|
18
18
|
import { compressCmd, removeBgCmd, upscaleCmd, removeWatermarkCmd } from './commands/img-tools.js';
|
|
19
|
-
import { slideshowCmd } from './commands/slideshow.js';
|
|
19
|
+
import { slideshowCmd, slideshowLayoutsCmd, createSlideshowLayoutCmd } from './commands/slideshow.js';
|
|
20
20
|
import { projects, createProject, listAssets, addAsset, removeAsset } from './commands/projects.js';
|
|
21
21
|
import { imageToTemplate } from './commands/image-to-template.js';
|
|
22
22
|
import { upload } from './commands/upload.js';
|
|
@@ -27,6 +27,7 @@ import { videoTemplates, createVideoTemplate } from './commands/video-templates.
|
|
|
27
27
|
import { balance } from './commands/balance.js';
|
|
28
28
|
import { jobs } from './commands/jobs.js';
|
|
29
29
|
import { whoami } from './commands/whoami.js';
|
|
30
|
+
import { feedback } from './commands/feedback.js';
|
|
30
31
|
import { products } from './commands/products.js';
|
|
31
32
|
import { assets } from './commands/assets.js';
|
|
32
33
|
import { stickers, createSticker } from './commands/stickers.js';
|
|
@@ -213,12 +214,24 @@ program
|
|
|
213
214
|
.option('--captions <file>', '文案文件(每行一条,按顺序对应每张图),与 --caption 二选一')
|
|
214
215
|
.option('--footer <text>', '底部引导语')
|
|
215
216
|
.option('--music <file>', '配乐音频。自动裁到视频长度并加首尾淡入淡出(不会中途硬切断)')
|
|
216
|
-
.option('--sec <n>', '
|
|
217
|
-
.option('--size <WxH>', '
|
|
218
|
-
.option('--
|
|
219
|
-
.option('--
|
|
220
|
-
.option('--
|
|
217
|
+
.option('--sec <n>', '每张停留秒数,默认取模板的(内置模板 2.5)')
|
|
218
|
+
.option('--size <WxH>', '画面尺寸,默认取模板画布(内置模板 1080x1920);与模板不同则整体等比缩放')
|
|
219
|
+
.option('--theme <name>', 'light(默认)/ dark。只影响内置版式,给了 --layout 就以模板为准')
|
|
220
|
+
.option('--layout <slug>', '排版模板 slug:内置的直接用,其余去中台拉(需登录)。清单见 museav slideshow-layouts')
|
|
221
|
+
.option('--layout-file <path>', '直接用本地 JSON 排版模板(调模板时用,免登录)')
|
|
221
222
|
.action(asyncRun((paths, opts) => slideshowCmd(paths, opts)));
|
|
223
|
+
const slideshowLayoutsCommand = program
|
|
224
|
+
.command('slideshow-layouts')
|
|
225
|
+
.description('查排版模板:内置版式(免登录)+ 中台模板(平台共享 / 本租户 / 本人私有)。配合 slideshow --layout 使用')
|
|
226
|
+
.action(asyncRun(() => slideshowLayoutsCmd()));
|
|
227
|
+
slideshowLayoutsCommand
|
|
228
|
+
.command('create <layout.json>')
|
|
229
|
+
.description('从 JSON 文件新建排版模板——归属由身份自动决定:租户 Key 建的归本租户,个人账号建的仅本人可见。结构校验在服务端做')
|
|
230
|
+
.requiredOption('--name <名称>', '模板中文名')
|
|
231
|
+
.requiredOption('--slug <slug>', '对外调用标识(小写字母/数字/连字符,全局唯一)')
|
|
232
|
+
.option('--description <text>', '模板说明')
|
|
233
|
+
.option('--category <name>', '分类,默认「其他」')
|
|
234
|
+
.action(asyncRun((file, opts) => createSlideshowLayoutCmd(file, opts)));
|
|
222
235
|
// 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
|
|
223
236
|
const projectsCmd = program
|
|
224
237
|
.command('projects')
|
|
@@ -376,6 +389,12 @@ program
|
|
|
376
389
|
.command('whoami')
|
|
377
390
|
.description('查当前登录账户 + 租户归属(仅个人 login 可用,apiKey 调用会报错)')
|
|
378
391
|
.action(withClient((client) => whoami(client)));
|
|
392
|
+
program
|
|
393
|
+
.command('feedback [content]')
|
|
394
|
+
.description('提 bug / 需求(不带内容则交互式输入);--list 看我的反馈记录。个人账户可提,租户 apiKey 会被服务端拒')
|
|
395
|
+
.option('--type <type>', '反馈类型:bug / 需求(默认 bug)')
|
|
396
|
+
.option('--list', '列出我的反馈记录')
|
|
397
|
+
.action(withLazyClient((getClient, content, opts) => feedback(getClient, content || '', opts)));
|
|
379
398
|
program
|
|
380
399
|
.command('config')
|
|
381
400
|
.description('配置中台地址和 apiKey(存到 ~/.museav.json)')
|
|
@@ -1,19 +1,62 @@
|
|
|
1
|
+
/** 图层类型。与中台 shared/slideshow-layout.js 的 LAYER_TYPES 对应。 */
|
|
2
|
+
export type LayerType = 'rect' | 'ellipse' | 'text' | 'slot' | 'image';
|
|
3
|
+
export interface Layer {
|
|
4
|
+
type: LayerType | string;
|
|
5
|
+
fill?: string;
|
|
6
|
+
opacity?: number;
|
|
7
|
+
box?: [number, number, number, number];
|
|
8
|
+
radius?: number;
|
|
9
|
+
fit?: 'contain' | 'cover';
|
|
10
|
+
trim?: boolean;
|
|
11
|
+
src?: string;
|
|
12
|
+
cx?: number;
|
|
13
|
+
cy?: number;
|
|
14
|
+
rx?: number;
|
|
15
|
+
ry?: number;
|
|
16
|
+
bind?: string | null;
|
|
17
|
+
text?: string;
|
|
18
|
+
x?: number;
|
|
19
|
+
y?: number;
|
|
20
|
+
size?: number;
|
|
21
|
+
weight?: number;
|
|
22
|
+
align?: 'left' | 'center' | 'right';
|
|
23
|
+
maxWidth?: number;
|
|
24
|
+
minSize?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Layout {
|
|
27
|
+
version?: number;
|
|
28
|
+
canvas: {
|
|
29
|
+
w: number;
|
|
30
|
+
h: number;
|
|
31
|
+
bg?: string;
|
|
32
|
+
};
|
|
33
|
+
seconds?: number;
|
|
34
|
+
layers: Layer[];
|
|
35
|
+
}
|
|
36
|
+
/** 渲染时填进模板的内容。caption / image 逐页变化,其余全局固定。 */
|
|
37
|
+
export interface PageContent {
|
|
38
|
+
title?: string;
|
|
39
|
+
subtitle?: string;
|
|
40
|
+
caption?: string;
|
|
41
|
+
footer?: string;
|
|
42
|
+
image: string;
|
|
43
|
+
}
|
|
1
44
|
export interface SlideshowOpts {
|
|
2
45
|
images: string[];
|
|
3
46
|
out: string;
|
|
47
|
+
layout: Layout;
|
|
48
|
+
captions?: string[];
|
|
4
49
|
title?: string;
|
|
5
50
|
subtitle?: string;
|
|
6
|
-
captions?: string[];
|
|
7
51
|
footer?: string;
|
|
8
52
|
music?: string;
|
|
9
53
|
seconds?: number;
|
|
54
|
+
/** 目标尺寸。与模板 canvas 不同时整个版面按比例缩放 */
|
|
10
55
|
width?: number;
|
|
11
56
|
height?: number;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/** 放大前先裁掉主图四周的透明留白,默认开(仅对带 alpha 的图生效) */
|
|
16
|
-
trim?: boolean;
|
|
57
|
+
/** 解析 sticker://<id> → 图片 URL/本地路径。不传则跳过这类图层 */
|
|
58
|
+
resolveSticker?: (id: string) => Promise<string | null>;
|
|
59
|
+
onWarn?: (msg: string) => void;
|
|
17
60
|
}
|
|
18
61
|
export declare function makeSlideshow(opts: SlideshowOpts): Promise<{
|
|
19
62
|
out: string;
|