museav-cli 2.10.2 → 3.0.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/CHANGELOG.md +45 -0
- package/README.md +18 -57
- package/dist/client.d.ts +0 -14
- package/dist/client.js +5 -19
- package/dist/index.js +36 -29
- package/package.json +1 -1
- package/src/client.ts +5 -24
- package/src/index.ts +39 -31
- package/dist/commands/slideshow.d.ts +0 -24
- package/dist/commands/slideshow.js +0 -182
- package/dist/local-slideshow.d.ts +0 -66
- package/dist/local-slideshow.js +0 -266
- package/dist/slideshow-presets.d.ts +0 -15
- package/dist/slideshow-presets.js +0 -29
- package/src/commands/slideshow.ts +0 -210
- package/src/local-slideshow.ts +0 -332
- package/src/slideshow-presets.ts +0 -42
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
}
|
|
44
|
-
export interface SlideshowOpts {
|
|
45
|
-
images: string[];
|
|
46
|
-
out: string;
|
|
47
|
-
layout: Layout;
|
|
48
|
-
captions?: string[];
|
|
49
|
-
title?: string;
|
|
50
|
-
subtitle?: string;
|
|
51
|
-
footer?: string;
|
|
52
|
-
music?: string;
|
|
53
|
-
seconds?: number;
|
|
54
|
-
/** 目标尺寸。与模板 canvas 不同时整个版面按比例缩放 */
|
|
55
|
-
width?: number;
|
|
56
|
-
height?: number;
|
|
57
|
-
/** 解析 sticker://<id> → 图片 URL/本地路径。不传则跳过这类图层 */
|
|
58
|
-
resolveSticker?: (id: string) => Promise<string | null>;
|
|
59
|
-
onWarn?: (msg: string) => void;
|
|
60
|
-
}
|
|
61
|
-
export declare function makeSlideshow(opts: SlideshowOpts): Promise<{
|
|
62
|
-
out: string;
|
|
63
|
-
pages: number;
|
|
64
|
-
seconds: number;
|
|
65
|
-
}>;
|
|
66
|
-
export declare function fileSize(p: string): Promise<number>;
|
package/dist/local-slideshow.js
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本地图集转竖版短视频 —— slideshow 的核心实现。
|
|
3
|
-
* 一组图 + 每张的文字说明 + 配乐 → mp4,适合发朋友圈 / 视频号 / 小红书。
|
|
4
|
-
*
|
|
5
|
-
* 版面由**排版模板(图层 DSL)**描述,不再写死在代码里。内置一套默认模板(免登录可用),
|
|
6
|
-
* `--layout <slug>` 可以拉中台 `slideshow_layouts` 里的模板 —— 模板结构的真源在中台的
|
|
7
|
-
* `shared/slideshow-layout.js`。
|
|
8
|
-
*
|
|
9
|
-
* ## 这里刻意不校验模板
|
|
10
|
-
*
|
|
11
|
-
* 校验只在中台写入时做。本模块是消费方:遇到不认识的层类型**跳过该层并提示升级 CLI**,
|
|
12
|
-
* 而不是报错中断。老版本 CLI 碰上新层类型应该少画一个图层,不是彻底出不了片。
|
|
13
|
-
* 两侧都校验的话,老 CLI 会把新写的合法模板判为非法,那才是真坏掉。
|
|
14
|
-
*
|
|
15
|
-
* 渲染走 sharp 渲染 SVG(中文靠系统字体),合成走 ffmpeg 的 concat demuxer ——
|
|
16
|
-
* 不引入 canvas 那种要编译的重依赖。
|
|
17
|
-
*
|
|
18
|
-
* 三个实测踩出来的点,写在这里免得下次重踩:
|
|
19
|
-
*
|
|
20
|
-
* 1. 图片必须显式放大。小图(如 240×240 的表情)直接贴到 1080 宽的画布上只占两成宽,
|
|
21
|
-
* 画面空得离谱。要按目标尺寸等比 resize,而不是「不超过某上限」那种只缩不放的逻辑。
|
|
22
|
-
* 贴纸类素材还要先裁掉四周透明留白(slot 层的 trim),否则放大的是留白、主体照样小。
|
|
23
|
-
* 2. 配乐不能直接 -shortest。配乐通常比视频长几倍,硬切会在中途断掉。
|
|
24
|
-
* 要 atrim 裁到视频时长 + 首尾 afade。
|
|
25
|
-
* 3. concat demuxer 的最后一张要再列一次,否则它的 duration 被忽略,末页一闪而过。
|
|
26
|
-
*/
|
|
27
|
-
import { mkdtemp, writeFile, rm, stat } from 'node:fs/promises';
|
|
28
|
-
import { tmpdir } from 'node:os';
|
|
29
|
-
import { join } from 'node:path';
|
|
30
|
-
import { spawn } from 'node:child_process';
|
|
31
|
-
const FONT = 'Hiragino Sans GB, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif';
|
|
32
|
-
function esc(s) {
|
|
33
|
-
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
34
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
35
|
-
}
|
|
36
|
-
/** 中文按全宽算、ASCII 按半宽算,估出一行文字的像素宽,用来收敛字号 */
|
|
37
|
-
function textWidth(s, size) {
|
|
38
|
-
let w = 0;
|
|
39
|
-
for (const ch of s)
|
|
40
|
-
w += /[一-鿿 -〿-]/.test(ch) ? size : size * 0.55;
|
|
41
|
-
return w;
|
|
42
|
-
}
|
|
43
|
-
function fitSize(text, max, start, min) {
|
|
44
|
-
let size = start;
|
|
45
|
-
while (size > min && textWidth(text, size) > max)
|
|
46
|
-
size -= 2;
|
|
47
|
-
return size;
|
|
48
|
-
}
|
|
49
|
-
/** 取 text 层这一页该显示的文字:有 bind 就取绑定值,否则用固定 text */
|
|
50
|
-
function resolveText(layer, content) {
|
|
51
|
-
if (!layer.bind)
|
|
52
|
-
return layer.text ?? '';
|
|
53
|
-
const v = content[layer.bind];
|
|
54
|
-
return typeof v === 'string' ? v : '';
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* 把一页拆成有序的渲染步骤。
|
|
58
|
-
*
|
|
59
|
-
* 关键点:**不能把图片层统一提到最后 composite**。sharp 的 composite 一律贴在底图之上,
|
|
60
|
-
* 那样所有图片就永远压在所有矢量层上面,DSL 里的图层顺序当场失效 ——
|
|
61
|
-
* 「图片铺满 + 文字压在上面」这种版式会渲染成文字被图片盖住(实测踩到)。
|
|
62
|
-
* 所以这里把连续的矢量层攒成一段 SVG,遇到图片层就断开,按原顺序逐段叠。
|
|
63
|
-
*
|
|
64
|
-
* 图片不嵌进 SVG 是另一回事:base64 内联会让 SVG 爆大且慢,走 composite 更划算。
|
|
65
|
-
*/
|
|
66
|
-
function buildPage(layout, content, scale, onWarn) {
|
|
67
|
-
const W = Math.round(layout.canvas.w * scale);
|
|
68
|
-
const H = Math.round(layout.canvas.h * scale);
|
|
69
|
-
const s = (n) => n * scale;
|
|
70
|
-
const steps = [];
|
|
71
|
-
let parts = [];
|
|
72
|
-
const unknown = new Set();
|
|
73
|
-
// 攒够的矢量层收成一段 SVG(透明底 —— 背景色由 base 图承担,否则后一段会盖掉前一段)
|
|
74
|
-
const flush = () => {
|
|
75
|
-
if (!parts.length)
|
|
76
|
-
return;
|
|
77
|
-
steps.push({ kind: 'svg', svg: `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>` });
|
|
78
|
-
parts = [];
|
|
79
|
-
};
|
|
80
|
-
for (const layer of layout.layers) {
|
|
81
|
-
const op = layer.opacity !== undefined ? ` opacity="${layer.opacity}"` : '';
|
|
82
|
-
if (layer.type === 'rect' && layer.box) {
|
|
83
|
-
const [x, y, w, h] = layer.box;
|
|
84
|
-
const r = layer.radius ? ` rx="${s(layer.radius)}"` : '';
|
|
85
|
-
parts.push(`<rect x="${s(x)}" y="${s(y)}" width="${s(w)}" height="${s(h)}"${r} fill="${layer.fill ?? '#000000'}"${op}/>`);
|
|
86
|
-
}
|
|
87
|
-
else if (layer.type === 'ellipse') {
|
|
88
|
-
parts.push(`<ellipse cx="${s(layer.cx ?? 0)}" cy="${s(layer.cy ?? 0)}" rx="${s(layer.rx ?? 0)}" ry="${s(layer.ry ?? 0)}" fill="${layer.fill ?? '#000000'}"${op}/>`);
|
|
89
|
-
}
|
|
90
|
-
else if (layer.type === 'text') {
|
|
91
|
-
const txt = resolveText(layer, content);
|
|
92
|
-
// 绑定值为空就整层不画(比如没给 --subtitle),而不是画一行空白占位
|
|
93
|
-
if (!txt)
|
|
94
|
-
continue;
|
|
95
|
-
const base = s(layer.size ?? 40);
|
|
96
|
-
const min = s(layer.minSize ?? Math.max(12, (layer.size ?? 40) * 0.5));
|
|
97
|
-
const max = (layer.maxWidth ?? 0.86) * W;
|
|
98
|
-
const size = fitSize(txt, max, base, min);
|
|
99
|
-
const anchor = layer.align === 'left' ? 'start' : layer.align === 'right' ? 'end' : 'middle';
|
|
100
|
-
const weight = layer.weight ? ` font-weight="${layer.weight}"` : '';
|
|
101
|
-
parts.push(`<text x="${s(layer.x ?? 0)}" y="${s(layer.y ?? 0)}" font-family="${FONT}" font-size="${size}"${weight} fill="${layer.fill ?? '#000000'}" text-anchor="${anchor}"${op}>${esc(txt)}</text>`);
|
|
102
|
-
}
|
|
103
|
-
else if ((layer.type === 'slot' || layer.type === 'image') && layer.box) {
|
|
104
|
-
const [x, y, w, h] = layer.box;
|
|
105
|
-
flush(); // 断开:此前的矢量层要落在这张图**下面**
|
|
106
|
-
steps.push({ kind: 'image', layer, box: [s(x), s(y), s(w), s(h)] });
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
// 不认识的层类型:跳过,不中断。老 CLI 碰上新层类型该少画一层,不是出不了片。
|
|
110
|
-
unknown.add(String(layer.type));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
flush();
|
|
114
|
-
if (unknown.size && onWarn) {
|
|
115
|
-
onWarn(`模板里有本版本不认识的图层类型(${[...unknown].join(', ')}),已跳过。升级试试:npm i -g museav-cli`);
|
|
116
|
-
}
|
|
117
|
-
return { steps, W, H };
|
|
118
|
-
}
|
|
119
|
-
function run(cmd, args) {
|
|
120
|
-
return new Promise((resolve) => {
|
|
121
|
-
const p = spawn(cmd, args);
|
|
122
|
-
let err = '';
|
|
123
|
-
p.stderr.on('data', (d) => { err += d.toString(); });
|
|
124
|
-
p.on('error', (e) => resolve({ code: 1, err: String(e) }));
|
|
125
|
-
p.on('close', (code) => resolve({ code: code ?? 1, err }));
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
async function hasFfmpeg() {
|
|
129
|
-
const { code } = await run('ffmpeg', ['-version']);
|
|
130
|
-
return code === 0;
|
|
131
|
-
}
|
|
132
|
-
export async function makeSlideshow(opts) {
|
|
133
|
-
if (!opts.images.length)
|
|
134
|
-
throw new Error('没有输入图片');
|
|
135
|
-
if (!(await hasFfmpeg()))
|
|
136
|
-
throw new Error('需要 ffmpeg(brew install ffmpeg)');
|
|
137
|
-
const sharpMod = await import('sharp').catch(() => null);
|
|
138
|
-
if (!sharpMod)
|
|
139
|
-
throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli');
|
|
140
|
-
const sharp = sharpMod.default ?? sharpMod;
|
|
141
|
-
const layout = opts.layout;
|
|
142
|
-
// 目标尺寸与模板 canvas 不同就整体等比缩放。取较小的比例,保证画面不被裁掉。
|
|
143
|
-
const targetW = opts.width ?? layout.canvas.w;
|
|
144
|
-
const targetH = opts.height ?? layout.canvas.h;
|
|
145
|
-
const scale = Math.min(targetW / layout.canvas.w, targetH / layout.canvas.h);
|
|
146
|
-
const sec = opts.seconds ?? layout.seconds ?? 2.5;
|
|
147
|
-
const work = await mkdtemp(join(tmpdir(), 'museav-slideshow-'));
|
|
148
|
-
// sticker:// 引用逐个解析一次就够,同一个贴图在每页都用得上
|
|
149
|
-
const stickerCache = new Map();
|
|
150
|
-
let warned = false;
|
|
151
|
-
const warn = (m) => { if (!warned) {
|
|
152
|
-
warned = true;
|
|
153
|
-
opts.onWarn?.(m);
|
|
154
|
-
} };
|
|
155
|
-
try {
|
|
156
|
-
const pages = [];
|
|
157
|
-
for (let i = 0; i < opts.images.length; i++) {
|
|
158
|
-
const content = {
|
|
159
|
-
title: opts.title,
|
|
160
|
-
subtitle: opts.subtitle,
|
|
161
|
-
caption: opts.captions?.[i],
|
|
162
|
-
footer: opts.footer,
|
|
163
|
-
image: opts.images[i],
|
|
164
|
-
};
|
|
165
|
-
const { steps, W, H } = buildPage(layout, content, scale, warn);
|
|
166
|
-
const composites = [];
|
|
167
|
-
for (const step of steps) {
|
|
168
|
-
// 矢量段按原顺序插进来,图片压在它之前的段之上、之后的段之下
|
|
169
|
-
if (step.kind === 'svg') {
|
|
170
|
-
composites.push({ input: Buffer.from(step.svg), top: 0, left: 0 });
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
const { layer, box } = step;
|
|
174
|
-
const [bx, by, bw, bh] = box;
|
|
175
|
-
let src = null;
|
|
176
|
-
if (layer.type === 'slot') {
|
|
177
|
-
src = content.image;
|
|
178
|
-
}
|
|
179
|
-
else if (layer.src?.startsWith('sticker://')) {
|
|
180
|
-
const id = layer.src.slice('sticker://'.length);
|
|
181
|
-
if (!stickerCache.has(id))
|
|
182
|
-
stickerCache.set(id, opts.resolveSticker ? await opts.resolveSticker(id) : null);
|
|
183
|
-
src = stickerCache.get(id) ?? null;
|
|
184
|
-
if (!src) {
|
|
185
|
-
warn(`模板引用的贴图 ${id} 取不到,已跳过该图层`);
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
src = layer.src ?? null;
|
|
191
|
-
}
|
|
192
|
-
if (!src)
|
|
193
|
-
continue;
|
|
194
|
-
let pipe = sharp(src.startsWith('http') ? Buffer.from(await (await fetch(src)).arrayBuffer()) : src);
|
|
195
|
-
// 贴纸类素材四周常有大片透明留白,直接放大等于把留白也放大、主体显小。
|
|
196
|
-
// 只对带 alpha 的图做 —— 对不透明照片 trim 会去裁纯色边框,那不是这里想要的。
|
|
197
|
-
if (layer.trim && (await pipe.metadata()).hasAlpha) {
|
|
198
|
-
const buf = await pipe.toBuffer();
|
|
199
|
-
pipe = sharp(buf).trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
|
|
200
|
-
}
|
|
201
|
-
// 显式放大:fit inside + withoutEnlargement:false,小图必须撑满槽位。
|
|
202
|
-
//
|
|
203
|
-
// 注意 box 的语义是**矩形框 + contain**(同 CSS object-fit),不是「正方形边长」。
|
|
204
|
-
// 2.9.0 那版把主图区当成正方形(resize(ART, ART)),对高瘦的图会受高度限制而偏小:
|
|
205
|
-
// 219×229 的图在 518×701 的框里,旧算法出 495×518,新算法出 518×542(大 4.6%)。
|
|
206
|
-
// 换成框语义是刻意的——框多大图就能占多大,这才符合直觉,也是主图偏小那个老问题的根治。
|
|
207
|
-
const art = await pipe
|
|
208
|
-
.resize(Math.round(bw), Math.round(bh), {
|
|
209
|
-
fit: layer.fit === 'cover' ? 'cover' : 'inside',
|
|
210
|
-
withoutEnlargement: false,
|
|
211
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
212
|
-
})
|
|
213
|
-
.png()
|
|
214
|
-
.toBuffer({ resolveWithObject: true });
|
|
215
|
-
// 在槽位内居中
|
|
216
|
-
composites.push({
|
|
217
|
-
input: art.data,
|
|
218
|
-
top: Math.max(0, Math.round(by + (bh - art.info.height) / 2)),
|
|
219
|
-
left: Math.max(0, Math.round(bx + (bw - art.info.width) / 2)),
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
// 底图只负责画布背景色,所有内容(含第一段矢量层)都按顺序 composite 上去
|
|
223
|
-
const page = join(work, `p${String(i).padStart(3, '0')}.png`);
|
|
224
|
-
const bg = layout.canvas.bg ?? '#ffffff';
|
|
225
|
-
await sharp({
|
|
226
|
-
create: { width: W, height: H, channels: 4, background: bg === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : bg },
|
|
227
|
-
})
|
|
228
|
-
.composite(composites)
|
|
229
|
-
.png()
|
|
230
|
-
.toFile(page);
|
|
231
|
-
pages.push(page);
|
|
232
|
-
}
|
|
233
|
-
// concat demuxer:末页要重复列一次,否则它的 duration 会被忽略
|
|
234
|
-
const listLines = [];
|
|
235
|
-
for (const p of pages)
|
|
236
|
-
listLines.push(`file '${p}'`, `duration ${sec}`);
|
|
237
|
-
listLines.push(`file '${pages[pages.length - 1]}'`);
|
|
238
|
-
const listFile = join(work, 'list.txt');
|
|
239
|
-
await writeFile(listFile, listLines.join('\n') + '\n', 'utf8');
|
|
240
|
-
const total = pages.length * sec;
|
|
241
|
-
const args = ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile];
|
|
242
|
-
if (opts.music)
|
|
243
|
-
args.push('-i', opts.music);
|
|
244
|
-
args.push('-c:v', 'libx264', '-r', '30', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '22');
|
|
245
|
-
if (opts.music) {
|
|
246
|
-
const fadeOut = Math.max(0, total - 1.5);
|
|
247
|
-
args.push('-af', `atrim=0:${total},afade=t=in:st=0:d=1,afade=t=out:st=${fadeOut}:d=1.5`, '-c:a', 'aac', '-b:a', '128k', '-shortest');
|
|
248
|
-
}
|
|
249
|
-
args.push(opts.out);
|
|
250
|
-
const { code, err } = await run('ffmpeg', args);
|
|
251
|
-
if (code !== 0)
|
|
252
|
-
throw new Error(`ffmpeg 失败:${err.trim().slice(0, 400)}`);
|
|
253
|
-
return { out: opts.out, pages: pages.length, seconds: total };
|
|
254
|
-
}
|
|
255
|
-
finally {
|
|
256
|
-
await rm(work, { recursive: true, force: true }).catch(() => { });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
export async function fileSize(p) {
|
|
260
|
-
try {
|
|
261
|
-
return (await stat(p)).size;
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
return 0;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 内置排版模板。不联网、不登录也能出片 —— 这是 slideshow 最重要的属性,
|
|
3
|
-
* 接中台模板库之后也必须保住。
|
|
4
|
-
*
|
|
5
|
-
* `sticker-pink-mint` 与中台 slideshow_layouts 里平台预置的同名模板**是同一份 DSL**
|
|
6
|
-
* (见 supabase/migrations/20260828120000_slideshow_layouts.sql)。存两份是刻意的:
|
|
7
|
-
* 中台那份是给人看、给人改的起点,这份是断网兜底。两份必须一致,改动时同步。
|
|
8
|
-
*
|
|
9
|
-
* 想要别的版面不要在这里加 —— 去中台建模板(museav slideshow-layouts create),
|
|
10
|
-
* 那才是排版体系的落点。这里只留「默认」和「深色默认」两套兜底。
|
|
11
|
-
*/
|
|
12
|
-
import type { Layout } from './local-slideshow.js';
|
|
13
|
-
export declare const PRESET_LIGHT: Layout;
|
|
14
|
-
export declare const PRESET_DARK: Layout;
|
|
15
|
-
export declare const PRESETS: Record<string, Layout>;
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export const PRESET_LIGHT = {
|
|
2
|
-
version: 1,
|
|
3
|
-
canvas: { w: 1080, h: 1920, bg: '#ffffff' },
|
|
4
|
-
seconds: 2.5,
|
|
5
|
-
layers: [
|
|
6
|
-
{ type: 'ellipse', cx: 190, cy: -131, rx: 510, ry: 430, fill: '#ffd6e2' },
|
|
7
|
-
{ type: 'ellipse', cx: 1110, cy: 1901, rx: 291, ry: 280, fill: '#baebe2' },
|
|
8
|
-
{ type: 'text', bind: 'title', x: 540, y: 394, size: 84, weight: 600, fill: '#2b2d31', align: 'center', maxWidth: 0.86, minSize: 36 },
|
|
9
|
-
{ type: 'text', bind: 'subtitle', x: 540, y: 495, size: 44, fill: '#8c929b', align: 'center' },
|
|
10
|
-
{ type: 'slot', bind: 'image', box: [281, 660, 518, 701], fit: 'contain', trim: true },
|
|
11
|
-
{ type: 'text', bind: 'caption', x: 540, y: 1578, size: 72, weight: 600, fill: '#2b2d31', align: 'center', maxWidth: 0.86, minSize: 28 },
|
|
12
|
-
{ type: 'text', bind: 'footer', x: 540, y: 1776, size: 33, fill: '#8c929b', align: 'center' },
|
|
13
|
-
],
|
|
14
|
-
};
|
|
15
|
-
export const PRESET_DARK = {
|
|
16
|
-
...PRESET_LIGHT,
|
|
17
|
-
canvas: { w: 1080, h: 1920, bg: '#16181c' },
|
|
18
|
-
layers: PRESET_LIGHT.layers.map((l) => {
|
|
19
|
-
if (l.type === 'ellipse')
|
|
20
|
-
return { ...l, fill: l.fill === '#ffd6e2' ? '#3a2b33' : '#22383a' };
|
|
21
|
-
if (l.type === 'text')
|
|
22
|
-
return { ...l, fill: l.fill === '#2b2d31' ? '#e9ebef' : '#8b929c' };
|
|
23
|
-
return l;
|
|
24
|
-
}),
|
|
25
|
-
};
|
|
26
|
-
export const PRESETS = {
|
|
27
|
-
'sticker-pink-mint': PRESET_LIGHT,
|
|
28
|
-
'sticker-pink-mint-dark': PRESET_DARK,
|
|
29
|
-
};
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/** museav slideshow —— 一组图 + 文案 + 配乐 → 竖版短视频。
|
|
2
|
-
* 纯本地渲染、不上传任何图片;stdout 只出产物路径,进度与统计打 stderr。
|
|
3
|
-
*
|
|
4
|
-
* 版面来自排版模板:不给 --layout 用内置的(**完全免登录、不碰网络**),
|
|
5
|
-
* 给了就去中台 slideshow_layouts 拉。免登录这条路必须一直留着——
|
|
6
|
-
* 秒级出片、零成本是这个命令的立身之本,不能因为接了模板库就没了。 */
|
|
7
|
-
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
8
|
-
import { extname, isAbsolute, join, resolve } from 'node:path'
|
|
9
|
-
import { makeSlideshow, fileSize, type Layout } 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'
|
|
13
|
-
|
|
14
|
-
const IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
|
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
|
-
async function collectImages(paths: string[]): Promise<string[]> {
|
|
22
|
-
const out: string[] = []
|
|
23
|
-
for (const p of paths) {
|
|
24
|
-
const abs = isAbsolute(p) ? p : resolve(p)
|
|
25
|
-
let st
|
|
26
|
-
try {
|
|
27
|
-
st = await stat(abs)
|
|
28
|
-
} catch {
|
|
29
|
-
throw new Error(`路径不存在: ${p}`)
|
|
30
|
-
}
|
|
31
|
-
if (st.isDirectory()) {
|
|
32
|
-
const names = (await readdir(abs))
|
|
33
|
-
.filter((n) => !n.startsWith('.') && IMG_EXT.has(extname(n).toLowerCase()))
|
|
34
|
-
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }))
|
|
35
|
-
if (!names.length) throw new Error(`目录里没有图片: ${p}`)
|
|
36
|
-
out.push(...names.map((n) => join(abs, n)))
|
|
37
|
-
} else {
|
|
38
|
-
out.push(abs)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return out
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 按需建 client:只有真要拉中台模板时才需要凭证,没有 --layout 就一路不碰网络 */
|
|
45
|
-
function studioClient(): StudioClient {
|
|
46
|
-
const cfg = loadConfig()
|
|
47
|
-
if (!cfg.token && !cfg.apiKey) {
|
|
48
|
-
throw new Error('拉中台排版模板需要登录:museav login(或配 apiKey)。\n不想登录就别传 --layout,内置版式免登录可用')
|
|
49
|
-
}
|
|
50
|
-
return new StudioClient({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, token: cfg.token })
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function loadLayout(opts: SlideshowCmdOpts): Promise<{ layout: Layout; label: string; client?: StudioClient }> {
|
|
54
|
-
if (opts.layoutFile) {
|
|
55
|
-
const raw = await readFile(opts.layoutFile, 'utf8').catch(() => {
|
|
56
|
-
throw new Error(`排版模板文件读不到: ${opts.layoutFile}`)
|
|
57
|
-
})
|
|
58
|
-
try {
|
|
59
|
-
return { layout: JSON.parse(raw) as Layout, label: `文件 ${opts.layoutFile}` }
|
|
60
|
-
} catch (e) {
|
|
61
|
-
throw new Error(`排版模板不是合法 JSON: ${(e as Error).message}`)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (opts.layout) {
|
|
66
|
-
// 内置的同名模板优先命中,省一次网络往返;不在内置里才去中台拉
|
|
67
|
-
const builtin = PRESETS[opts.layout]
|
|
68
|
-
if (builtin) return { layout: builtin, label: `内置 ${opts.layout}` }
|
|
69
|
-
const client = studioClient()
|
|
70
|
-
const row = await client.slideshowLayout(opts.layout)
|
|
71
|
-
if (!row?.layout) throw new Error(`模板 ${opts.layout} 没有 layout 内容`)
|
|
72
|
-
return { layout: row.layout as Layout, label: `中台 ${row.name}(${row.slug})`, client }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const dark = (opts.theme || 'light') === 'dark'
|
|
76
|
-
return { layout: dark ? PRESET_DARK : PRESET_LIGHT, label: dark ? '内置深色' : '内置默认' }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface SlideshowCmdOpts {
|
|
80
|
-
out?: string
|
|
81
|
-
title?: string
|
|
82
|
-
subtitle?: string
|
|
83
|
-
footer?: string
|
|
84
|
-
caption?: string[]
|
|
85
|
-
captions?: string
|
|
86
|
-
music?: string
|
|
87
|
-
sec?: string
|
|
88
|
-
size?: string
|
|
89
|
-
theme?: string
|
|
90
|
-
layout?: string
|
|
91
|
-
layoutFile?: string
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function slideshowCmd(paths: string[], opts: SlideshowCmdOpts): Promise<void> {
|
|
95
|
-
const images = await collectImages(paths)
|
|
96
|
-
|
|
97
|
-
let captions = opts.caption?.length ? [...opts.caption] : []
|
|
98
|
-
if (opts.captions) {
|
|
99
|
-
const text = await readFile(opts.captions, 'utf8').catch(() => {
|
|
100
|
-
throw new Error(`文案文件读不到: ${opts.captions}`)
|
|
101
|
-
})
|
|
102
|
-
captions = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
103
|
-
}
|
|
104
|
-
if (captions.length && captions.length < images.length) {
|
|
105
|
-
process.stderr.write(`⚠️ 文案 ${captions.length} 条少于图片 ${images.length} 张,后面几张不带文字\n`)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const { layout, label, client } = await loadLayout(opts)
|
|
109
|
-
|
|
110
|
-
const sec = opts.sec ? Number(opts.sec) : undefined
|
|
111
|
-
if (opts.sec && (!Number.isFinite(sec) || (sec as number) <= 0)) throw new Error('--sec 必须是正数')
|
|
112
|
-
|
|
113
|
-
let width: number | undefined
|
|
114
|
-
let height: number | undefined
|
|
115
|
-
if (opts.size) {
|
|
116
|
-
const m = /^(\d+)[x×](\d+)$/i.exec(opts.size.trim())
|
|
117
|
-
if (!m) throw new Error('--size 格式是 宽x高,如 1080x1920')
|
|
118
|
-
width = Number(m[1])
|
|
119
|
-
height = Number(m[2])
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const out = opts.out ? resolve(opts.out) : resolve('slideshow.mp4')
|
|
123
|
-
const effSec = sec ?? layout.seconds ?? 2.5
|
|
124
|
-
const w = width ?? layout.canvas.w
|
|
125
|
-
const h = height ?? layout.canvas.h
|
|
126
|
-
process.stderr.write(
|
|
127
|
-
`${images.length} 张 × ${effSec}s ≈ ${(images.length * effSec).toFixed(0)}s · ${w}×${h} · 版式:${label}` +
|
|
128
|
-
(opts.title ? ` · 「${opts.title}」` : '') +
|
|
129
|
-
(opts.music ? ' · 带配乐' : ' · 无配乐') + '\n',
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
// 贴图只在模板真的引用了 sticker:// 时才去拉,且整轮只拉一次清单
|
|
133
|
-
let stickerUrls: Map<string, string> | null = null
|
|
134
|
-
const resolveSticker = async (id: string): Promise<string | null> => {
|
|
135
|
-
if (!stickerUrls) {
|
|
136
|
-
const c = client ?? studioClient()
|
|
137
|
-
const rows = await c.stickers().catch(() => [])
|
|
138
|
-
stickerUrls = new Map(rows.filter((r: any) => r?.id && r?.url).map((r: any) => [r.id, r.url]))
|
|
139
|
-
}
|
|
140
|
-
return stickerUrls.get(id) ?? null
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const start = Date.now()
|
|
144
|
-
const r = await makeSlideshow({
|
|
145
|
-
images, out, layout, width, height, seconds: sec,
|
|
146
|
-
title: opts.title, subtitle: opts.subtitle, footer: opts.footer,
|
|
147
|
-
captions: captions.length ? captions : undefined,
|
|
148
|
-
music: opts.music ? resolve(opts.music) : undefined,
|
|
149
|
-
resolveSticker,
|
|
150
|
-
onWarn: (m) => process.stderr.write(`⚠️ ${m}\n`),
|
|
151
|
-
})
|
|
152
|
-
process.stderr.write(
|
|
153
|
-
`✅ 视频生成完成(${r.pages} 页,${r.seconds.toFixed(1)}s,` +
|
|
154
|
-
`${fmtBytes(await fileSize(out))},用时 ${((Date.now() - start) / 1000).toFixed(1)}s)\n`,
|
|
155
|
-
)
|
|
156
|
-
console.log(out)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** museav slideshow-layouts —— 列出可用排版模板(内置 + 中台) */
|
|
160
|
-
export async function slideshowLayoutsCmd(): Promise<void> {
|
|
161
|
-
process.stderr.write('内置版式(免登录可用):\n')
|
|
162
|
-
for (const slug of Object.keys(PRESETS)) {
|
|
163
|
-
process.stderr.write(` ${slug.padEnd(28)} ${PRESETS[slug].canvas.w}×${PRESETS[slug].canvas.h}\n`)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let rows: any[] = []
|
|
167
|
-
try {
|
|
168
|
-
rows = await studioClient().slideshowLayouts()
|
|
169
|
-
} catch (e) {
|
|
170
|
-
// 没登录不算错:内置版式已经列出来了,这条命令仍然有用
|
|
171
|
-
process.stderr.write(`\n(中台模板未列出:${(e as Error).message.split('\n')[0]})\n`)
|
|
172
|
-
console.log(Object.keys(PRESETS).join('\n'))
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (rows.length) {
|
|
177
|
-
process.stderr.write(`\n中台模板(${rows.length} 个):\n`)
|
|
178
|
-
for (const r of rows) {
|
|
179
|
-
const src = r.source === 'platform' ? '[平台]' : r.source === 'mine' ? '[租户]' : '[私有]'
|
|
180
|
-
const size = r.layout?.canvas ? `${r.layout.canvas.w}×${r.layout.canvas.h}` : ''
|
|
181
|
-
process.stderr.write(` ${String(r.slug).padEnd(28)} ${String(r.name).padEnd(20)} ${size.padEnd(10)} ${src}\n`)
|
|
182
|
-
}
|
|
183
|
-
} else {
|
|
184
|
-
process.stderr.write('\n中台还没有模板。用 museav slideshow-layouts create 建一个。\n')
|
|
185
|
-
}
|
|
186
|
-
process.stderr.write('\n出片: museav slideshow ./图片目录 --layout <slug>\n')
|
|
187
|
-
console.log([...Object.keys(PRESETS), ...rows.map((r) => r.slug)].join('\n'))
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** museav slideshow-layouts create —— 从 JSON 文件建一个排版模板 */
|
|
191
|
-
export async function createSlideshowLayoutCmd(file: string, opts: { name: string; slug: string; description?: string; category?: string }): Promise<void> {
|
|
192
|
-
const raw = await readFile(file, 'utf8').catch(() => {
|
|
193
|
-
throw new Error(`读不到文件: ${file}`)
|
|
194
|
-
})
|
|
195
|
-
let layout: unknown
|
|
196
|
-
try {
|
|
197
|
-
layout = JSON.parse(raw)
|
|
198
|
-
} catch (e) {
|
|
199
|
-
throw new Error(`不是合法 JSON: ${(e as Error).message}`)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const row = await studioClient().createSlideshowLayout({
|
|
203
|
-
name: opts.name, slug: opts.slug, layout,
|
|
204
|
-
description: opts.description, category: opts.category,
|
|
205
|
-
})
|
|
206
|
-
process.stderr.write(`✅ 排版模板已建:${row.name}(${row.slug})\n`)
|
|
207
|
-
process.stderr.write(`归属:${row.visibility === 'private' ? '仅本人可见' : row.tenant_id ? '本租户共享' : '平台共享'}\n`)
|
|
208
|
-
process.stderr.write(`\n出片: museav slideshow ./图片目录 --layout ${row.slug}\n`)
|
|
209
|
-
console.log(row.id)
|
|
210
|
-
}
|