museav-cli 2.7.0 → 2.9.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 CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.9.0 (2026-08-28)
4
+
5
+ ### 新增:`slideshow` —— 一组图 + 文案 + 配乐 → 竖版短视频(免登录)
6
+
7
+ ```bash
8
+ museav slideshow ./表情图 --title "莓啾日常" --caption "催外卖 / 催开饭" --music bgm.mp3
9
+ ```
10
+
11
+ 默认 1080×1920 / 30fps / H.264+AAC。排版走 sharp + SVG(中文用系统字体,不引入需要编译的
12
+ canvas),合成走 ffmpeg(**要求本机装了 ffmpeg**,这是唯一的外部依赖)。
13
+
14
+ 这个能力原本长在微信表情包的 skill 里(一个 Python 脚本),做推广视频用。但「图集 + 文案 +
15
+ 配乐 → 竖版视频」跟表情包没关系,产品图、作品集、日报一样要用,所以下沉到 CLI,
16
+ skill 那边退化成读配置拼参数的薄封装。
17
+
18
+ 移植时保留了三个已经踩过的坑的修法,它们的共同点还是**不报错、只是结果不对**:
19
+
20
+ 1. **小图不会被自动放大。** PIL 的 `thumbnail` 和 sharp 的 `withoutEnlargement` 都只缩不放,
21
+ 240×240 的贴纸贴到 1080 宽的画布上会原样贴,在竖屏里只占两成宽。要显式 `resize` 到目标尺寸。
22
+ 贴纸类图片还得先 `trim` 掉四周透明留白再放大——否则放大的是留白,主体照样小。
23
+ 2. **配乐直接 `-shortest` 会中途硬切断。** 配乐通常比视频长几倍,切在句子中间很难听。
24
+ 要 `atrim` 裁到视频时长 + 首尾 `afade`。
25
+ 3. **ffmpeg 的 concat demuxer 会忽略最后一项的 `duration`。** 末页一闪而过,
26
+ 只能把末页在列表里多写一次。
27
+
28
+ ## 2.8.0 (2026-08-27)
29
+
30
+ ### 新增:贴图素材 + 版式模板命令(租户级资产,给封面/海报工具用)
31
+
32
+ 两个新命令,底层调中台 `/api/stickers` 和 `/api/poster-templates`,账户 Key / 租户 Key 均可(落到本租户)。
33
+
34
+ - `museav stickers`:列出本租户贴图素材(PNG 透明装饰图)
35
+ - `museav stickers add <file> --name <名称>`:上传贴图(不压缩,保留透明通道)
36
+ - `museav poster-templates`:列出版式模板(封面底图 + 固定描述)
37
+ - `museav poster-templates add <file> --name <名称> --prompt <描述>`:保存版式(`{城市}` `{明星}` 占位符会被替换)
38
+
39
+ 背景:好易美(hym)的闲鱼封面工具此前版式存学员本机桌面、贴图只在后台本地,无法共享。
40
+ 现在贴图素材 + 版式模板收归中台,CLI 一条命令加载/保存,学员间共享。
41
+
3
42
  ## 2.7.0 (2026-08-27)
4
43
 
5
44
  ### 修复:`remove-bg` 抠不出主体(三个静默 bug)
package/README.md CHANGED
@@ -102,11 +102,12 @@
102
102
  | | 抠图去背景(输出透明 PNG) | `remove-bg` |
103
103
  | | **放大清晰度**(2M → 10M+ 级) | `upscale` |
104
104
  | | 去水印 | `remove-watermark` |
105
+ | | **一组图 → 竖版短视频**(带标题/文案/配乐) | `slideshow` |
105
106
  | **语音**(直连小米 MiMo,需 `MIMO_API_KEY`) | 文字转语音 / 音色设计 / 音色克隆 | `speak` |
106
107
  | | 语音转文字 | `transcribe` |
107
108
  | **素材与统计** | 上传素材 / 查任务 / 查余额 / 查模型 / 查身份 | `upload` / `jobs` / `balance` / `models` / `whoami` |
108
109
 
109
- > 本地工具(`compress` / `remove-bg` / `upscale` / `remove-watermark`)**不用登录、不花一分钱**,装了就能用;其余命令需要一个凭证(个人 `login` 或租户 apiKey)。
110
+ > 本地工具(`compress` / `remove-bg` / `upscale` / `remove-watermark` / `slideshow`)**不用登录、不花一分钱**,装了就能用;其余命令需要一个凭证(个人 `login` 或租户 apiKey)。
110
111
  >
111
112
  > 语音(`speak` / `transcribe`)是第三种情况:**不走中台身份**,直连小米 MiMo,只认 `MIMO_API_KEY` 环境变量。
112
113
  > 中台的出音链路还没接完,这批能力目前内部用,没有那把 key 的人(包括租户)用不了。
@@ -440,6 +441,36 @@ museav remove-watermark photo.jpg --mask mask.png # 复杂画面手工掩
440
441
  - 依赖红线:本地**绝不自动拉起开源大模型**——这些工具是轻量 CNN/传统算法,用完即释放内存。reverse 的本地路已翻回显式 `--local`(需自备 Ollama),默认走中台 API。
441
442
  - 二进制与模型缓存:`~/.museav-bin/upscayl`(引擎)、`~/.museav-models/`(模型),Windows 对应 `%USERPROFILE%` 下同名目录;删除即彻底清理。
442
443
 
444
+ ### 图集转竖版短视频 `slideshow`(免登录)
445
+
446
+ 一组图 + 每张的文案 + 一段配乐 → 一条能直接发朋友圈 / 视频号 / 小红书的竖版视频。
447
+ 排版走 sharp + SVG(中文用系统字体),合成走 ffmpeg,**需要本机装了 ffmpeg**(`brew install ffmpeg`)。
448
+
449
+ ```bash
450
+ # 最简:一个目录里的图,按文件名排序串成视频
451
+ museav slideshow ./图片目录 --out 成片.mp4
452
+
453
+ # 完整:标题 + 逐张文案 + 配乐
454
+ museav slideshow ./表情图 \
455
+ --title "莓啾日常" --subtitle "莓啾 · 微信表情包" \
456
+ --caption "需要被安慰的时候" --caption "催外卖 / 催开饭" \
457
+ --footer "微信搜「莓啾日常」添加整套" \
458
+ --music bgm.mp3 --sec 2.2 --out 推广视频.mp4
459
+
460
+ # 文案多的时候写文件里,每行一条
461
+ museav slideshow ./图片目录 --captions 文案.txt --title "作品集"
462
+ ```
463
+
464
+ 默认 1080×1920 / 30fps / H.264+AAC,每张停 2.5 秒;`--size` 换尺寸、`--theme dark` 换深色底。
465
+
466
+ 三个不写出来就会踩的点,已经在实现里处理掉了:
467
+
468
+ - **小图会被显式放大**。240×240 的贴纸直接贴到 1080 宽的画布上只占两成宽,画面空得离谱。
469
+ 贴纸类图片还会先裁掉四周透明留白再放大(`--no-trim` 关掉),否则放大的是留白不是主体。
470
+ - **配乐不会中途硬切断**。配乐通常比视频长几倍,直接 `-shortest` 会在句子中间断掉;
471
+ 这里裁到视频时长并做首尾淡入淡出。
472
+ - **末页不会一闪而过**。ffmpeg 的 concat demuxer 会忽略最后一项的 duration,实现里把末页多列了一次。
473
+
443
474
  ### 项目(工作区)与项目素材库 `projects`
444
475
 
445
476
  层级:**平台 → 账户 → 工作区**。一个账户最多 5 个项目,每个项目挂自己的素材库——「人像库的项目出模特图、产品库的项目出电商图」,业务隔离互不污染:
@@ -564,6 +595,7 @@ console.log(r.sculpt.light) // 光影分析
564
595
  | `remove-bg <file>` | 本地抠图去背景(免登录,输出带 alpha 的 PNG) | 产物路径 |
565
596
  | `upscale <file>` | 本地超分放大,2M 图变 10M+(免登录,Real-ESRGAN GPU,`--scale 2/3/4`) | 产物路径 |
566
597
  | `remove-watermark <file>` | 本地去水印(免登录,自动定位 + LaMa 修复,`--mask` 手工兜底) | 产物路径 |
598
+ | `slideshow <图片或目录...>` | 图集转竖版短视频(免登录,带标题/逐张文案/配乐,需 ffmpeg) | 产物路径 |
567
599
  | `projects` | 工作区(项目)列表:一账户多项目,各带自己的素材库 | 项目 id 列表 |
568
600
  | `projects assets --project` | 项目素材库:ls / add / rm(垫图母版,人像库/产品库各管各的) | `id<TAB>url` 行 |
569
601
  | `models` | 可用模型 | 模型名列表 |
package/dist/client.d.ts CHANGED
@@ -337,6 +337,18 @@ export declare class StudioClient {
337
337
  createTemplate(input: CreateTemplateInput): Promise<TemplateOption>;
338
338
  /** 新建视频模板。归属同图片模板:租户 apiKey 自动归租户,平台管理员归平台共享 */
339
339
  createVideoTemplate(input: CreateVideoTemplateInput): Promise<TemplateOption>;
340
+ /** 贴图素材清单(租户级资产,服务端已按调用者权限过滤)。PNG 装饰图,叠加在海报/封面上 */
341
+ stickers(): Promise<any[]>;
342
+ /** 上传贴图素材(不压缩——透明 PNG 压缩会破坏透明通道) */
343
+ createSticker(filePath: string, name: string): Promise<any>;
344
+ /** 删除贴图素材 */
345
+ deleteSticker(id: string): Promise<void>;
346
+ /** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
347
+ posterTemplates(): Promise<any[]>;
348
+ /** 上传版式模板(不压缩,保留封面底图原样) */
349
+ createPosterTemplate(filePath: string, name: string, prompt: string): Promise<any>;
350
+ /** 删除版式模板 */
351
+ deletePosterTemplate(id: string): Promise<void>;
340
352
  /** 提交出图任务,立即返回 jobId */
341
353
  generate(opts: GenerateOptions): Promise<{
342
354
  jobId: string;
package/dist/client.js CHANGED
@@ -125,6 +125,41 @@ export class StudioClient {
125
125
  });
126
126
  return r.row;
127
127
  }
128
+ /** 贴图素材清单(租户级资产,服务端已按调用者权限过滤)。PNG 装饰图,叠加在海报/封面上 */
129
+ async stickers() {
130
+ const r = await this.request('stickers');
131
+ return Array.isArray(r) ? r : [];
132
+ }
133
+ /** 上传贴图素材(不压缩——透明 PNG 压缩会破坏透明通道) */
134
+ async createSticker(filePath, name) {
135
+ const blob = new Blob([new Uint8Array(readFileSync(filePath))]);
136
+ const fd = new FormData();
137
+ fd.append('file', blob, basename(filePath));
138
+ fd.append('name', name);
139
+ return this.request('stickers', { method: 'POST', body: fd });
140
+ }
141
+ /** 删除贴图素材 */
142
+ async deleteSticker(id) {
143
+ await this.request(`stickers?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
144
+ }
145
+ /** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
146
+ async posterTemplates() {
147
+ const r = await this.request('poster-templates');
148
+ return Array.isArray(r) ? r : [];
149
+ }
150
+ /** 上传版式模板(不压缩,保留封面底图原样) */
151
+ async createPosterTemplate(filePath, name, prompt) {
152
+ const blob = new Blob([new Uint8Array(readFileSync(filePath))]);
153
+ const fd = new FormData();
154
+ fd.append('file', blob, basename(filePath));
155
+ fd.append('name', name);
156
+ fd.append('prompt', prompt);
157
+ return this.request('poster-templates', { method: 'POST', body: fd });
158
+ }
159
+ /** 删除版式模板 */
160
+ async deletePosterTemplate(id) {
161
+ await this.request(`poster-templates?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
162
+ }
128
163
  /** 提交出图任务,立即返回 jobId */
129
164
  async generate(opts) {
130
165
  // prompt / skill_slug / template_id 三选一:都传时服务端按 prompt > template_id > skill_slug
@@ -0,0 +1,11 @@
1
+ /**
2
+ * museav poster-templates —— 版式模板库(租户级资产)。
3
+ * 封面底图 + 固定描述({城市} {明星} 占位符),选版式时把城市/明星名填进底图。
4
+ * 学员做的封面版式保存后全租户共享。
5
+ */
6
+ import type { StudioClient } from '../client.js';
7
+ export declare function posterTemplates(client: StudioClient): Promise<void>;
8
+ export declare function createPosterTemplate(client: StudioClient, filePath: string, opts: {
9
+ name: string;
10
+ prompt: string;
11
+ }): Promise<void>;
@@ -0,0 +1,23 @@
1
+ export async function posterTemplates(client) {
2
+ const list = await client.posterTemplates();
3
+ if (!list.length) {
4
+ process.stderr.write('没有版式模板(可用 museav poster-templates add <底图> --name <名称> --prompt <描述> 保存)\n');
5
+ return;
6
+ }
7
+ process.stderr.write(`版式模板(${list.length} 个):\n`);
8
+ for (const t of list) {
9
+ process.stderr.write(` ${t.id.padEnd(38)} ${String(t.name || '').padEnd(18)} ${String(t.prompt || '').slice(0, 40)}\n`);
10
+ }
11
+ process.stderr.write(`\n保存: museav poster-templates add <底图路径> --name <名称> --prompt <描述>\n`);
12
+ console.log(list.map((t) => t.id).join('\n'));
13
+ }
14
+ export async function createPosterTemplate(client, filePath, opts) {
15
+ if (!opts.name?.trim())
16
+ throw new Error('--name 必填');
17
+ if (!opts.prompt?.trim())
18
+ throw new Error('--prompt 必填,{城市} {明星} 占位符会被替换');
19
+ const row = await client.createPosterTemplate(filePath, opts.name, opts.prompt);
20
+ const id = row?.template?.id || row?.id || '';
21
+ process.stderr.write(`✅ 版式模板已保存:${id}\n`);
22
+ console.log(id);
23
+ }
@@ -0,0 +1,15 @@
1
+ export interface SlideshowCmdOpts {
2
+ out?: string;
3
+ title?: string;
4
+ subtitle?: string;
5
+ footer?: string;
6
+ caption?: string[];
7
+ captions?: string;
8
+ music?: string;
9
+ sec?: string;
10
+ size?: string;
11
+ art?: string;
12
+ theme?: string;
13
+ trim?: boolean;
14
+ }
15
+ export declare function slideshowCmd(paths: string[], opts: SlideshowCmdOpts): Promise<void>;
@@ -0,0 +1,82 @@
1
+ /** museav slideshow —— 一组图 + 文案 + 配乐 → 竖版短视频。
2
+ * 纯本地、免登录、不碰中台;stdout 只出产物路径,进度与统计打 stderr。
3
+ * 排版走 sharp + SVG(中文用系统字体),合成走 ffmpeg,不引入需要编译的重依赖。 */
4
+ import { readFile, readdir, stat } from 'node:fs/promises';
5
+ import { extname, isAbsolute, join, resolve } from 'node:path';
6
+ import { makeSlideshow, fileSize } from '../local-slideshow.js';
7
+ const IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp']);
8
+ function fmtBytes(n) {
9
+ return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`;
10
+ }
11
+ /** 入参可以是若干张图,也可以是一个目录(目录内按文件名排序,所以图片建议带序号前缀) */
12
+ async function collectImages(paths) {
13
+ const out = [];
14
+ for (const p of paths) {
15
+ const abs = isAbsolute(p) ? p : resolve(p);
16
+ let st;
17
+ try {
18
+ st = await stat(abs);
19
+ }
20
+ catch {
21
+ throw new Error(`路径不存在: ${p}`);
22
+ }
23
+ if (st.isDirectory()) {
24
+ const names = (await readdir(abs))
25
+ .filter((n) => !n.startsWith('.') && IMG_EXT.has(extname(n).toLowerCase()))
26
+ .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
27
+ if (!names.length)
28
+ throw new Error(`目录里没有图片: ${p}`);
29
+ out.push(...names.map((n) => join(abs, n)));
30
+ }
31
+ else {
32
+ out.push(abs);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ export async function slideshowCmd(paths, opts) {
38
+ const images = await collectImages(paths);
39
+ let captions = opts.caption?.length ? [...opts.caption] : [];
40
+ if (opts.captions) {
41
+ const text = await readFile(opts.captions, 'utf8').catch(() => {
42
+ throw new Error(`文案文件读不到: ${opts.captions}`);
43
+ });
44
+ captions = text.split('\n').map((l) => l.trim()).filter(Boolean);
45
+ }
46
+ if (captions.length && captions.length < images.length) {
47
+ process.stderr.write(`⚠️ 文案 ${captions.length} 条少于图片 ${images.length} 张,后面几张不带文字\n`);
48
+ }
49
+ const sec = opts.sec ? Number(opts.sec) : 2.5;
50
+ if (!Number.isFinite(sec) || sec <= 0)
51
+ throw new Error('--sec 必须是正数');
52
+ let width = 1080;
53
+ let height = 1920;
54
+ if (opts.size) {
55
+ const m = /^(\d+)[x×](\d+)$/i.exec(opts.size.trim());
56
+ if (!m)
57
+ throw new Error('--size 格式是 宽x高,如 1080x1920');
58
+ width = Number(m[1]);
59
+ height = Number(m[2]);
60
+ }
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
+ const out = opts.out ? resolve(opts.out) : resolve('slideshow.mp4');
68
+ const total = images.length * sec;
69
+ process.stderr.write(`${images.length} 张 × ${sec}s ≈ ${total.toFixed(0)}s · ${width}×${height}` +
70
+ (opts.title ? ` · 「${opts.title}」` : '') +
71
+ (opts.music ? ' · 带配乐' : ' · 无配乐') + '\n');
72
+ const start = Date.now();
73
+ const r = await makeSlideshow({
74
+ images, out, width, height, artSize, seconds: sec, theme, trim: opts.trim,
75
+ title: opts.title, subtitle: opts.subtitle, footer: opts.footer,
76
+ captions: captions.length ? captions : undefined,
77
+ music: opts.music ? resolve(opts.music) : undefined,
78
+ });
79
+ process.stderr.write(`✅ 视频生成完成(${r.pages} 页,${r.seconds.toFixed(1)}s,` +
80
+ `${fmtBytes(await fileSize(out))},用时 ${((Date.now() - start) / 1000).toFixed(1)}s)\n`);
81
+ console.log(out);
82
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * museav stickers —— 贴图素材库(租户级资产)。
3
+ * PNG 透明装饰图(logo/花纹/边框),叠加在海报/封面上。
4
+ * 列出的都是自己租户的贴图;上传用账户 Key 或租户 Key 均可(落到本租户)。
5
+ */
6
+ import type { StudioClient } from '../client.js';
7
+ export declare function stickers(client: StudioClient): Promise<void>;
8
+ export declare function createSticker(client: StudioClient, filePath: string, opts: {
9
+ name: string;
10
+ }): Promise<void>;
@@ -0,0 +1,22 @@
1
+ export async function stickers(client) {
2
+ const list = await client.stickers();
3
+ if (!list.length) {
4
+ process.stderr.write('没有贴图素材(可用 museav stickers add <图片> --name <名称> 上传)\n');
5
+ return;
6
+ }
7
+ process.stderr.write(`贴图素材(${list.length} 个):\n`);
8
+ for (const s of list) {
9
+ process.stderr.write(` ${s.id.padEnd(38)} ${String(s.name || '').padEnd(20)} ${s.url || ''}\n`);
10
+ }
11
+ process.stderr.write(`\n上传: museav stickers add <图片路径> --name <名称>\n`);
12
+ // stdout 只出 id,便于脚本与 agent 解析
13
+ console.log(list.map((s) => s.id).join('\n'));
14
+ }
15
+ export async function createSticker(client, filePath, opts) {
16
+ if (!opts.name?.trim())
17
+ throw new Error('--name 必填');
18
+ const row = await client.createSticker(filePath, opts.name);
19
+ const id = row?.sticker?.id || row?.id || '';
20
+ process.stderr.write(`✅ 贴图素材已上传:${id}\n`);
21
+ console.log(id);
22
+ }
package/dist/index.js CHANGED
@@ -16,6 +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
20
  import { projects, createProject, listAssets, addAsset, removeAsset } from './commands/projects.js';
20
21
  import { imageToTemplate } from './commands/image-to-template.js';
21
22
  import { upload } from './commands/upload.js';
@@ -28,6 +29,8 @@ import { jobs } from './commands/jobs.js';
28
29
  import { whoami } from './commands/whoami.js';
29
30
  import { products } from './commands/products.js';
30
31
  import { assets } from './commands/assets.js';
32
+ import { stickers, createSticker } from './commands/stickers.js';
33
+ import { posterTemplates, createPosterTemplate } from './commands/poster-templates.js';
31
34
  import { speak, transcribeCmd } from './commands/speak.js';
32
35
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
33
36
  // 每 12 小时最多查一次 npm registry,过期才提示,不拖慢日常调用
@@ -200,6 +203,22 @@ program
200
203
  .option('--mask <file>', '手工掩码图(白色=要去除的区域),跳过自动定位')
201
204
  .option('--overwrite', '允许覆盖已存在的输出文件')
202
205
  .action(asyncRun((input, opts) => removeWatermarkCmd(input, opts)));
206
+ program
207
+ .command('slideshow <图片或目录...>')
208
+ .description('本地图集转竖版短视频(sharp + ffmpeg,免登录):默认 1080×1920 / 30fps / H.264,可加标题、逐张文案与配乐。需要本机有 ffmpeg')
209
+ .option('--out <path>', '输出 mp4(默认当前目录 slideshow.mp4)')
210
+ .option('--title <text>', '顶部主标题(大字)')
211
+ .option('--subtitle <text>', '顶部副标题(小字)')
212
+ .option('--caption <text>', '单页文案,可重复传,按顺序对应每张图', (v, acc) => [...acc, v], [])
213
+ .option('--captions <file>', '文案文件(每行一条,按顺序对应每张图),与 --caption 二选一')
214
+ .option('--footer <text>', '底部引导语')
215
+ .option('--music <file>', '配乐音频。自动裁到视频长度并加首尾淡入淡出(不会中途硬切断)')
216
+ .option('--sec <n>', '每张停留秒数,默认 2.5')
217
+ .option('--size <WxH>', '画面尺寸,默认 1080x1920')
218
+ .option('--art <px>', '主图渲染边长,默认画面宽的 48%')
219
+ .option('--theme <name>', 'light(默认)/ dark')
220
+ .option('--no-trim', '不裁主图四周的透明留白(默认会裁,贴纸类图片不裁会显得很小)')
221
+ .action(asyncRun((paths, opts) => slideshowCmd(paths, opts)));
203
222
  // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
204
223
  const projectsCmd = program
205
224
  .command('projects')
@@ -315,6 +334,25 @@ templatesCmd
315
334
  .option('--fields <json>', '占位符字段说明,JSON 数组,如 \'[{"key":"artist","label":"艺人名"}]\';不传则自动从 --prompt 里的 {key} 提取')
316
335
  .option('--type <type>', '模板类型:image(图片,默认) / article(文字)')
317
336
  .action(withClient((client, opts) => createTemplate(client, opts)));
337
+ const stickersCmd = program
338
+ .command('stickers')
339
+ .description('查贴图素材库(租户级资产:PNG 装饰图,叠加在海报/封面上)')
340
+ .action(withClient((client) => stickers(client)));
341
+ stickersCmd
342
+ .command('add <file>')
343
+ .description('上传贴图素材(PNG 透明装饰图,不压缩保留透明通道)')
344
+ .requiredOption('--name <名称>', '贴图名称')
345
+ .action(withClient((client, file, opts) => createSticker(client, file, opts)));
346
+ const posterTemplatesCmd = program
347
+ .command('poster-templates')
348
+ .description('查版式模板库(租户级资产:封面底图 + 固定描述,选版式把城市/明星名填进底图)')
349
+ .action(withClient((client) => posterTemplates(client)));
350
+ posterTemplatesCmd
351
+ .command('add <file>')
352
+ .description('保存版式模板(封面底图 + 描述,描述里用 {城市} {明星} 占位)')
353
+ .requiredOption('--name <名称>', '版式名称')
354
+ .requiredOption('--prompt <描述>', '版式固定描述,{城市} {明星} 会自动替换')
355
+ .action(withClient((client, file, opts) => createPosterTemplate(client, file, opts)));
318
356
  program
319
357
  .command('products')
320
358
  .description('查所属租户自己的产品目录(数据在租户自己的后台,不在 Studio 中台;只支持已开通该接口的租户,仅租户 apiKey 身份可用)')
@@ -0,0 +1,23 @@
1
+ export interface SlideshowOpts {
2
+ images: string[];
3
+ out: string;
4
+ title?: string;
5
+ subtitle?: string;
6
+ captions?: string[];
7
+ footer?: string;
8
+ music?: string;
9
+ seconds?: number;
10
+ width?: number;
11
+ height?: number;
12
+ /** 主图渲染尺寸(正方形边长)。默认 520,约占 1080 宽的 48% */
13
+ artSize?: number;
14
+ theme?: 'light' | 'dark';
15
+ /** 放大前先裁掉主图四周的透明留白,默认开(仅对带 alpha 的图生效) */
16
+ trim?: boolean;
17
+ }
18
+ export declare function makeSlideshow(opts: SlideshowOpts): Promise<{
19
+ out: string;
20
+ pages: number;
21
+ seconds: number;
22
+ }>;
23
+ export declare function fileSize(p: string): Promise<number>;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 本地图集转竖版短视频 —— slideshow 的核心实现。
3
+ * 一组图 + 每张的文字说明 + 配乐 → 1080×1920 的 mp4,适合发朋友圈 / 视频号 / 小红书。
4
+ *
5
+ * 排版走 sharp 渲染 SVG(中文靠系统字体,macOS/Win/主流 Linux 都有中文字体),
6
+ * 合成走 ffmpeg 的 concat demuxer —— 不引入 canvas 那种要编译的重依赖。
7
+ *
8
+ * 三个实测踩出来的点,写在这里免得下次重踩:
9
+ *
10
+ * 1. 图片必须显式放大。小图(如 240×240 的表情)直接贴到 1080 宽的画布上只占两成宽,
11
+ * 画面空得离谱。要按目标尺寸等比 resize,而不是「不超过某上限」那种只缩不放的逻辑。
12
+ * 2. 配乐不能直接 -shortest。配乐通常比视频长几倍,硬切会在中途断掉。
13
+ * 要 atrim 裁到视频时长 + 首尾 afade。
14
+ * 3. concat demuxer 的最后一张要再列一次,否则它的 duration 被忽略,末页一闪而过。
15
+ */
16
+ import { mkdtemp, writeFile, rm, stat } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { spawn } from 'node:child_process';
20
+ const FONT = 'Hiragino Sans GB, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif';
21
+ function esc(s) {
22
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
24
+ }
25
+ /** 中文按全宽算、ASCII 按半宽算,估出一行文字的像素宽,用来收敛字号 */
26
+ function textWidth(s, size) {
27
+ let w = 0;
28
+ for (const ch of s)
29
+ w += /[一-鿿 -〿＀-￯]/.test(ch) ? size : size * 0.55;
30
+ return w;
31
+ }
32
+ function fitSize(text, max, start, min = 24) {
33
+ let size = start;
34
+ while (size > min && textWidth(text, size) > max)
35
+ size -= 2;
36
+ return size;
37
+ }
38
+ function pageSvg(o) {
39
+ const { width: W, height: H, theme } = o;
40
+ const dark = theme === 'dark';
41
+ const bg = dark ? '#16181c' : '#ffffff';
42
+ const ink = dark ? '#e9ebef' : '#2b2d31';
43
+ const muted = dark ? '#8b929c' : '#8c929b';
44
+ const pink = dark ? '#3a2b33' : '#ffd6e2';
45
+ const mint = dark ? '#22383a' : '#baebe2';
46
+ const parts = [
47
+ `<rect width="${W}" height="${H}" fill="${bg}"/>`,
48
+ // 左上 / 右下两块装饰弧,位置固定,整套看起来是同一个模板
49
+ `<ellipse cx="${W * 0.176}" cy="${-H * 0.068}" rx="${W * 0.472}" ry="${H * 0.224}" fill="${pink}"/>`,
50
+ `<ellipse cx="${W * 1.028}" cy="${H * 0.99}" rx="${W * 0.269}" ry="${H * 0.146}" fill="${mint}"/>`,
51
+ ];
52
+ if (o.title) {
53
+ const s = fitSize(o.title, W * 0.86, Math.round(W * 0.078), 36);
54
+ parts.push(`<text x="${W / 2}" y="${H * 0.205}" font-family="${FONT}" font-size="${s}" font-weight="600" fill="${ink}" text-anchor="middle">${esc(o.title)}</text>`);
55
+ }
56
+ if (o.subtitle) {
57
+ const s = Math.round(W * 0.041);
58
+ parts.push(`<text x="${W / 2}" y="${H * 0.258}" font-family="${FONT}" font-size="${s}" fill="${muted}" text-anchor="middle">${esc(o.subtitle)}</text>`);
59
+ }
60
+ if (o.caption) {
61
+ const s = fitSize(o.caption, W * 0.86, Math.round(W * 0.067), 28);
62
+ parts.push(`<text x="${W / 2}" y="${H * 0.822}" font-family="${FONT}" font-size="${s}" font-weight="600" fill="${ink}" text-anchor="middle">${esc(o.caption)}</text>`);
63
+ }
64
+ if (o.footer) {
65
+ const s = Math.round(W * 0.031);
66
+ parts.push(`<text x="${W / 2}" y="${H * 0.925}" font-family="${FONT}" font-size="${s}" fill="${muted}" text-anchor="middle">${esc(o.footer)}</text>`);
67
+ }
68
+ return `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>`;
69
+ }
70
+ function run(cmd, args) {
71
+ return new Promise((resolve) => {
72
+ const p = spawn(cmd, args);
73
+ let err = '';
74
+ p.stderr.on('data', (d) => { err += d.toString(); });
75
+ p.on('error', (e) => resolve({ code: 1, err: String(e) }));
76
+ p.on('close', (code) => resolve({ code: code ?? 1, err }));
77
+ });
78
+ }
79
+ async function hasFfmpeg() {
80
+ const { code } = await run('ffmpeg', ['-version']);
81
+ return code === 0;
82
+ }
83
+ export async function makeSlideshow(opts) {
84
+ if (!opts.images.length)
85
+ throw new Error('没有输入图片');
86
+ if (!(await hasFfmpeg()))
87
+ throw new Error('需要 ffmpeg(brew install ffmpeg)');
88
+ const sharpMod = await import('sharp').catch(() => null);
89
+ if (!sharpMod)
90
+ throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli');
91
+ const sharp = sharpMod.default ?? sharpMod;
92
+ const W = opts.width ?? 1080;
93
+ const H = opts.height ?? 1920;
94
+ const ART = opts.artSize ?? 520;
95
+ const sec = opts.seconds ?? 2.5;
96
+ const theme = opts.theme ?? 'light';
97
+ const work = await mkdtemp(join(tmpdir(), 'museav-slideshow-'));
98
+ try {
99
+ const pages = [];
100
+ for (let i = 0; i < opts.images.length; i++) {
101
+ const bg = Buffer.from(pageSvg({
102
+ width: W, height: H, theme,
103
+ title: opts.title, subtitle: opts.subtitle,
104
+ caption: opts.captions?.[i], footer: opts.footer,
105
+ }));
106
+ // 贴纸类图片四周常有大片透明留白,直接放大等于把留白也放大、主体显小。
107
+ // 先裁到主体外接框再放大,画面才撑得住。只对带 alpha 的图做——
108
+ // 对不透明照片 trim 会去裁纯色边框,那不是这里想要的。
109
+ let pipe = sharp(opts.images[i]);
110
+ if (opts.trim !== false && (await pipe.metadata()).hasAlpha) {
111
+ pipe = sharp(opts.images[i]).trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
112
+ }
113
+ // 显式等比 resize 到 ART:小图必须放大,否则在竖屏里小得可怜
114
+ const art = await pipe
115
+ .resize(ART, ART, { fit: 'inside', withoutEnlargement: false, background: { r: 0, g: 0, b: 0, alpha: 0 } })
116
+ .png()
117
+ .toBuffer({ resolveWithObject: true });
118
+ const top = Math.round(H * 0.344 + (H * 0.365 - art.info.height) / 2);
119
+ const left = Math.round((W - art.info.width) / 2);
120
+ const page = join(work, `p${String(i).padStart(3, '0')}.png`);
121
+ await sharp(bg).composite([{ input: art.data, top: Math.max(0, top), left: Math.max(0, left) }]).png().toFile(page);
122
+ pages.push(page);
123
+ }
124
+ // concat demuxer:末页要重复列一次,否则它的 duration 会被忽略
125
+ const listLines = [];
126
+ for (const p of pages)
127
+ listLines.push(`file '${p}'`, `duration ${sec}`);
128
+ listLines.push(`file '${pages[pages.length - 1]}'`);
129
+ const listFile = join(work, 'list.txt');
130
+ await writeFile(listFile, listLines.join('\n') + '\n', 'utf8');
131
+ const total = pages.length * sec;
132
+ const args = ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile];
133
+ if (opts.music)
134
+ args.push('-i', opts.music);
135
+ args.push('-c:v', 'libx264', '-r', '30', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '22');
136
+ if (opts.music) {
137
+ const fadeOut = Math.max(0, total - 1.5);
138
+ 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');
139
+ }
140
+ args.push(opts.out);
141
+ const { code, err } = await run('ffmpeg', args);
142
+ if (code !== 0)
143
+ throw new Error(`ffmpeg 失败:${err.trim().slice(0, 400)}`);
144
+ return { out: opts.out, pages: pages.length, seconds: total };
145
+ }
146
+ finally {
147
+ await rm(work, { recursive: true, force: true }).catch(() => { });
148
+ }
149
+ }
150
+ export async function fileSize(p) {
151
+ try {
152
+ return (await stat(p)).size;
153
+ }
154
+ catch {
155
+ return 0;
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.ts CHANGED
@@ -432,6 +432,47 @@ export class StudioClient {
432
432
  return r.row
433
433
  }
434
434
 
435
+ /** 贴图素材清单(租户级资产,服务端已按调用者权限过滤)。PNG 装饰图,叠加在海报/封面上 */
436
+ async stickers(): Promise<any[]> {
437
+ const r = await this.request('stickers')
438
+ return Array.isArray(r) ? r : []
439
+ }
440
+
441
+ /** 上传贴图素材(不压缩——透明 PNG 压缩会破坏透明通道) */
442
+ async createSticker(filePath: string, name: string): Promise<any> {
443
+ const blob = new Blob([new Uint8Array(readFileSync(filePath))])
444
+ const fd = new FormData()
445
+ fd.append('file', blob, basename(filePath))
446
+ fd.append('name', name)
447
+ return this.request('stickers', { method: 'POST', body: fd })
448
+ }
449
+
450
+ /** 删除贴图素材 */
451
+ async deleteSticker(id: string): Promise<void> {
452
+ await this.request(`stickers?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
453
+ }
454
+
455
+ /** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
456
+ async posterTemplates(): Promise<any[]> {
457
+ const r = await this.request('poster-templates')
458
+ return Array.isArray(r) ? r : []
459
+ }
460
+
461
+ /** 上传版式模板(不压缩,保留封面底图原样) */
462
+ async createPosterTemplate(filePath: string, name: string, prompt: string): Promise<any> {
463
+ const blob = new Blob([new Uint8Array(readFileSync(filePath))])
464
+ const fd = new FormData()
465
+ fd.append('file', blob, basename(filePath))
466
+ fd.append('name', name)
467
+ fd.append('prompt', prompt)
468
+ return this.request('poster-templates', { method: 'POST', body: fd })
469
+ }
470
+
471
+ /** 删除版式模板 */
472
+ async deletePosterTemplate(id: string): Promise<void> {
473
+ await this.request(`poster-templates?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
474
+ }
475
+
435
476
  /** 提交出图任务,立即返回 jobId */
436
477
  async generate(opts: GenerateOptions): Promise<{ jobId: string; trace_id?: string }> {
437
478
  // prompt / skill_slug / template_id 三选一:都传时服务端按 prompt > template_id > skill_slug
@@ -0,0 +1,33 @@
1
+ /**
2
+ * museav poster-templates —— 版式模板库(租户级资产)。
3
+ * 封面底图 + 固定描述({城市} {明星} 占位符),选版式时把城市/明星名填进底图。
4
+ * 学员做的封面版式保存后全租户共享。
5
+ */
6
+ import type { StudioClient } from '../client.js'
7
+
8
+ export async function posterTemplates(client: StudioClient): Promise<void> {
9
+ const list = await client.posterTemplates()
10
+ if (!list.length) {
11
+ process.stderr.write('没有版式模板(可用 museav poster-templates add <底图> --name <名称> --prompt <描述> 保存)\n')
12
+ return
13
+ }
14
+ process.stderr.write(`版式模板(${list.length} 个):\n`)
15
+ for (const t of list) {
16
+ process.stderr.write(` ${t.id.padEnd(38)} ${String(t.name || '').padEnd(18)} ${String(t.prompt || '').slice(0, 40)}\n`)
17
+ }
18
+ process.stderr.write(`\n保存: museav poster-templates add <底图路径> --name <名称> --prompt <描述>\n`)
19
+ console.log(list.map((t) => t.id).join('\n'))
20
+ }
21
+
22
+ export async function createPosterTemplate(
23
+ client: StudioClient,
24
+ filePath: string,
25
+ opts: { name: string; prompt: string },
26
+ ): Promise<void> {
27
+ if (!opts.name?.trim()) throw new Error('--name 必填')
28
+ if (!opts.prompt?.trim()) throw new Error('--prompt 必填,{城市} {明星} 占位符会被替换')
29
+ const row = await client.createPosterTemplate(filePath, opts.name, opts.prompt)
30
+ const id = row?.template?.id || row?.id || ''
31
+ process.stderr.write(`✅ 版式模板已保存:${id}\n`)
32
+ console.log(id)
33
+ }
@@ -0,0 +1,103 @@
1
+ /** museav slideshow —— 一组图 + 文案 + 配乐 → 竖版短视频。
2
+ * 纯本地、免登录、不碰中台;stdout 只出产物路径,进度与统计打 stderr。
3
+ * 排版走 sharp + SVG(中文用系统字体),合成走 ffmpeg,不引入需要编译的重依赖。 */
4
+ import { readFile, readdir, stat } from 'node:fs/promises'
5
+ import { extname, isAbsolute, join, resolve } from 'node:path'
6
+ import { makeSlideshow, fileSize } from '../local-slideshow.js'
7
+
8
+ const IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp'])
9
+
10
+ function fmtBytes(n: number): string {
11
+ return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`
12
+ }
13
+
14
+ /** 入参可以是若干张图,也可以是一个目录(目录内按文件名排序,所以图片建议带序号前缀) */
15
+ async function collectImages(paths: string[]): Promise<string[]> {
16
+ const out: string[] = []
17
+ for (const p of paths) {
18
+ const abs = isAbsolute(p) ? p : resolve(p)
19
+ let st
20
+ try {
21
+ st = await stat(abs)
22
+ } catch {
23
+ throw new Error(`路径不存在: ${p}`)
24
+ }
25
+ if (st.isDirectory()) {
26
+ const names = (await readdir(abs))
27
+ .filter((n) => !n.startsWith('.') && IMG_EXT.has(extname(n).toLowerCase()))
28
+ .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }))
29
+ if (!names.length) throw new Error(`目录里没有图片: ${p}`)
30
+ out.push(...names.map((n) => join(abs, n)))
31
+ } else {
32
+ out.push(abs)
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
38
+ export interface SlideshowCmdOpts {
39
+ out?: string
40
+ title?: string
41
+ subtitle?: string
42
+ footer?: string
43
+ caption?: string[]
44
+ captions?: string
45
+ music?: string
46
+ sec?: string
47
+ size?: string
48
+ art?: string
49
+ theme?: string
50
+ trim?: boolean
51
+ }
52
+
53
+ export async function slideshowCmd(paths: string[], opts: SlideshowCmdOpts): Promise<void> {
54
+ const images = await collectImages(paths)
55
+
56
+ let captions = opts.caption?.length ? [...opts.caption] : []
57
+ if (opts.captions) {
58
+ const text = await readFile(opts.captions, 'utf8').catch(() => {
59
+ throw new Error(`文案文件读不到: ${opts.captions}`)
60
+ })
61
+ captions = text.split('\n').map((l) => l.trim()).filter(Boolean)
62
+ }
63
+ if (captions.length && captions.length < images.length) {
64
+ process.stderr.write(`⚠️ 文案 ${captions.length} 条少于图片 ${images.length} 张,后面几张不带文字\n`)
65
+ }
66
+
67
+ const sec = opts.sec ? Number(opts.sec) : 2.5
68
+ if (!Number.isFinite(sec) || sec <= 0) throw new Error('--sec 必须是正数')
69
+
70
+ let width = 1080
71
+ let height = 1920
72
+ if (opts.size) {
73
+ const m = /^(\d+)[x×](\d+)$/i.exec(opts.size.trim())
74
+ if (!m) throw new Error('--size 格式是 宽x高,如 1080x1920')
75
+ width = Number(m[1])
76
+ height = Number(m[2])
77
+ }
78
+ const artSize = opts.art ? Number(opts.art) : Math.round(width * 0.48)
79
+ if (!Number.isFinite(artSize) || artSize < 16) throw new Error('--art 至少 16px')
80
+ const theme = (opts.theme || 'light') as 'light' | 'dark'
81
+ if (!['light', 'dark'].includes(theme)) throw new Error('--theme 只支持 light / dark')
82
+
83
+ const out = opts.out ? resolve(opts.out) : resolve('slideshow.mp4')
84
+ const total = images.length * sec
85
+ process.stderr.write(
86
+ `${images.length} 张 × ${sec}s ≈ ${total.toFixed(0)}s · ${width}×${height}` +
87
+ (opts.title ? ` · 「${opts.title}」` : '') +
88
+ (opts.music ? ' · 带配乐' : ' · 无配乐') + '\n',
89
+ )
90
+
91
+ const start = Date.now()
92
+ const r = await makeSlideshow({
93
+ images, out, width, height, artSize, seconds: sec, theme, trim: opts.trim,
94
+ title: opts.title, subtitle: opts.subtitle, footer: opts.footer,
95
+ captions: captions.length ? captions : undefined,
96
+ music: opts.music ? resolve(opts.music) : undefined,
97
+ })
98
+ process.stderr.write(
99
+ `✅ 视频生成完成(${r.pages} 页,${r.seconds.toFixed(1)}s,` +
100
+ `${fmtBytes(await fileSize(out))},用时 ${((Date.now() - start) / 1000).toFixed(1)}s)\n`,
101
+ )
102
+ console.log(out)
103
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * museav stickers —— 贴图素材库(租户级资产)。
3
+ * PNG 透明装饰图(logo/花纹/边框),叠加在海报/封面上。
4
+ * 列出的都是自己租户的贴图;上传用账户 Key 或租户 Key 均可(落到本租户)。
5
+ */
6
+ import type { StudioClient } from '../client.js'
7
+
8
+ export async function stickers(client: StudioClient): Promise<void> {
9
+ const list = await client.stickers()
10
+ if (!list.length) {
11
+ process.stderr.write('没有贴图素材(可用 museav stickers add <图片> --name <名称> 上传)\n')
12
+ return
13
+ }
14
+ process.stderr.write(`贴图素材(${list.length} 个):\n`)
15
+ for (const s of list) {
16
+ process.stderr.write(` ${s.id.padEnd(38)} ${String(s.name || '').padEnd(20)} ${s.url || ''}\n`)
17
+ }
18
+ process.stderr.write(`\n上传: museav stickers add <图片路径> --name <名称>\n`)
19
+ // stdout 只出 id,便于脚本与 agent 解析
20
+ console.log(list.map((s) => s.id).join('\n'))
21
+ }
22
+
23
+ export async function createSticker(client: StudioClient, filePath: string, opts: { name: string }): Promise<void> {
24
+ if (!opts.name?.trim()) throw new Error('--name 必填')
25
+ const row = await client.createSticker(filePath, opts.name)
26
+ const id = row?.sticker?.id || row?.id || ''
27
+ process.stderr.write(`✅ 贴图素材已上传:${id}\n`)
28
+ console.log(id)
29
+ }
package/src/index.ts CHANGED
@@ -16,6 +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
20
  import { projects, createProject, listAssets, addAsset, removeAsset, resolveWorkspace } from './commands/projects.js'
20
21
  import { imageToTemplate } from './commands/image-to-template.js'
21
22
  import { upload } from './commands/upload.js'
@@ -28,6 +29,8 @@ import { jobs } from './commands/jobs.js'
28
29
  import { whoami } from './commands/whoami.js'
29
30
  import { products } from './commands/products.js'
30
31
  import { assets } from './commands/assets.js'
32
+ import { stickers, createSticker } from './commands/stickers.js'
33
+ import { posterTemplates, createPosterTemplate } from './commands/poster-templates.js'
31
34
  import { speak, transcribeCmd } from './commands/speak.js'
32
35
 
33
36
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { name: string; version: string }
@@ -208,6 +211,23 @@ program
208
211
  .option('--overwrite', '允许覆盖已存在的输出文件')
209
212
  .action(asyncRun((input: string, opts: any) => removeWatermarkCmd(input, opts)))
210
213
 
214
+ program
215
+ .command('slideshow <图片或目录...>')
216
+ .description('本地图集转竖版短视频(sharp + ffmpeg,免登录):默认 1080×1920 / 30fps / H.264,可加标题、逐张文案与配乐。需要本机有 ffmpeg')
217
+ .option('--out <path>', '输出 mp4(默认当前目录 slideshow.mp4)')
218
+ .option('--title <text>', '顶部主标题(大字)')
219
+ .option('--subtitle <text>', '顶部副标题(小字)')
220
+ .option('--caption <text>', '单页文案,可重复传,按顺序对应每张图', (v: string, acc: string[]) => [...acc, v], [] as string[])
221
+ .option('--captions <file>', '文案文件(每行一条,按顺序对应每张图),与 --caption 二选一')
222
+ .option('--footer <text>', '底部引导语')
223
+ .option('--music <file>', '配乐音频。自动裁到视频长度并加首尾淡入淡出(不会中途硬切断)')
224
+ .option('--sec <n>', '每张停留秒数,默认 2.5')
225
+ .option('--size <WxH>', '画面尺寸,默认 1080x1920')
226
+ .option('--art <px>', '主图渲染边长,默认画面宽的 48%')
227
+ .option('--theme <name>', 'light(默认)/ dark')
228
+ .option('--no-trim', '不裁主图四周的透明留白(默认会裁,贴纸类图片不裁会显得很小)')
229
+ .action(asyncRun((paths: string[], opts: any) => slideshowCmd(paths, opts)))
230
+
211
231
  // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
212
232
  const projectsCmd = program
213
233
  .command('projects')
@@ -345,6 +365,29 @@ templatesCmd
345
365
  .option('--type <type>', '模板类型:image(图片,默认) / article(文字)')
346
366
  .action(withClient((client: StudioClient, opts: any) => createTemplate(client, opts)))
347
367
 
368
+ const stickersCmd = program
369
+ .command('stickers')
370
+ .description('查贴图素材库(租户级资产:PNG 装饰图,叠加在海报/封面上)')
371
+ .action(withClient((client: StudioClient) => stickers(client)))
372
+
373
+ stickersCmd
374
+ .command('add <file>')
375
+ .description('上传贴图素材(PNG 透明装饰图,不压缩保留透明通道)')
376
+ .requiredOption('--name <名称>', '贴图名称')
377
+ .action(withClient((client: StudioClient, file: string, opts: any) => createSticker(client, file, opts)))
378
+
379
+ const posterTemplatesCmd = program
380
+ .command('poster-templates')
381
+ .description('查版式模板库(租户级资产:封面底图 + 固定描述,选版式把城市/明星名填进底图)')
382
+ .action(withClient((client: StudioClient) => posterTemplates(client)))
383
+
384
+ posterTemplatesCmd
385
+ .command('add <file>')
386
+ .description('保存版式模板(封面底图 + 描述,描述里用 {城市} {明星} 占位)')
387
+ .requiredOption('--name <名称>', '版式名称')
388
+ .requiredOption('--prompt <描述>', '版式固定描述,{城市} {明星} 会自动替换')
389
+ .action(withClient((client: StudioClient, file: string, opts: any) => createPosterTemplate(client, file, opts)))
390
+
348
391
  program
349
392
  .command('products')
350
393
  .description('查所属租户自己的产品目录(数据在租户自己的后台,不在 Studio 中台;只支持已开通该接口的租户,仅租户 apiKey 身份可用)')
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 本地图集转竖版短视频 —— slideshow 的核心实现。
3
+ * 一组图 + 每张的文字说明 + 配乐 → 1080×1920 的 mp4,适合发朋友圈 / 视频号 / 小红书。
4
+ *
5
+ * 排版走 sharp 渲染 SVG(中文靠系统字体,macOS/Win/主流 Linux 都有中文字体),
6
+ * 合成走 ffmpeg 的 concat demuxer —— 不引入 canvas 那种要编译的重依赖。
7
+ *
8
+ * 三个实测踩出来的点,写在这里免得下次重踩:
9
+ *
10
+ * 1. 图片必须显式放大。小图(如 240×240 的表情)直接贴到 1080 宽的画布上只占两成宽,
11
+ * 画面空得离谱。要按目标尺寸等比 resize,而不是「不超过某上限」那种只缩不放的逻辑。
12
+ * 2. 配乐不能直接 -shortest。配乐通常比视频长几倍,硬切会在中途断掉。
13
+ * 要 atrim 裁到视频时长 + 首尾 afade。
14
+ * 3. concat demuxer 的最后一张要再列一次,否则它的 duration 被忽略,末页一闪而过。
15
+ */
16
+ import { mkdtemp, writeFile, rm, stat } from 'node:fs/promises'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { spawn } from 'node:child_process'
20
+
21
+ export interface SlideshowOpts {
22
+ images: string[]
23
+ out: string
24
+ title?: string
25
+ subtitle?: string
26
+ captions?: string[]
27
+ footer?: string
28
+ music?: string
29
+ seconds?: number
30
+ width?: number
31
+ height?: number
32
+ /** 主图渲染尺寸(正方形边长)。默认 520,约占 1080 宽的 48% */
33
+ artSize?: number
34
+ theme?: 'light' | 'dark'
35
+ /** 放大前先裁掉主图四周的透明留白,默认开(仅对带 alpha 的图生效) */
36
+ trim?: boolean
37
+ }
38
+
39
+ const FONT = 'Hiragino Sans GB, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif'
40
+
41
+ function esc(s: string): string {
42
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
43
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
44
+ }
45
+
46
+ /** 中文按全宽算、ASCII 按半宽算,估出一行文字的像素宽,用来收敛字号 */
47
+ function textWidth(s: string, size: number): number {
48
+ let w = 0
49
+ for (const ch of s) w += /[一-鿿 -〿＀-￯]/.test(ch) ? size : size * 0.55
50
+ return w
51
+ }
52
+
53
+ function fitSize(text: string, max: number, start: number, min = 24): number {
54
+ let size = start
55
+ while (size > min && textWidth(text, size) > max) size -= 2
56
+ return size
57
+ }
58
+
59
+ function pageSvg(o: Required<Pick<SlideshowOpts, 'width' | 'height'>> & {
60
+ title?: string; subtitle?: string; caption?: string; footer?: string; theme: 'light' | 'dark'
61
+ }): string {
62
+ const { width: W, height: H, theme } = o
63
+ const dark = theme === 'dark'
64
+ const bg = dark ? '#16181c' : '#ffffff'
65
+ const ink = dark ? '#e9ebef' : '#2b2d31'
66
+ const muted = dark ? '#8b929c' : '#8c929b'
67
+ const pink = dark ? '#3a2b33' : '#ffd6e2'
68
+ const mint = dark ? '#22383a' : '#baebe2'
69
+
70
+ const parts = [
71
+ `<rect width="${W}" height="${H}" fill="${bg}"/>`,
72
+ // 左上 / 右下两块装饰弧,位置固定,整套看起来是同一个模板
73
+ `<ellipse cx="${W * 0.176}" cy="${-H * 0.068}" rx="${W * 0.472}" ry="${H * 0.224}" fill="${pink}"/>`,
74
+ `<ellipse cx="${W * 1.028}" cy="${H * 0.99}" rx="${W * 0.269}" ry="${H * 0.146}" fill="${mint}"/>`,
75
+ ]
76
+ if (o.title) {
77
+ const s = fitSize(o.title, W * 0.86, Math.round(W * 0.078), 36)
78
+ parts.push(`<text x="${W / 2}" y="${H * 0.205}" font-family="${FONT}" font-size="${s}" font-weight="600" fill="${ink}" text-anchor="middle">${esc(o.title)}</text>`)
79
+ }
80
+ if (o.subtitle) {
81
+ const s = Math.round(W * 0.041)
82
+ parts.push(`<text x="${W / 2}" y="${H * 0.258}" font-family="${FONT}" font-size="${s}" fill="${muted}" text-anchor="middle">${esc(o.subtitle)}</text>`)
83
+ }
84
+ if (o.caption) {
85
+ const s = fitSize(o.caption, W * 0.86, Math.round(W * 0.067), 28)
86
+ parts.push(`<text x="${W / 2}" y="${H * 0.822}" font-family="${FONT}" font-size="${s}" font-weight="600" fill="${ink}" text-anchor="middle">${esc(o.caption)}</text>`)
87
+ }
88
+ if (o.footer) {
89
+ const s = Math.round(W * 0.031)
90
+ parts.push(`<text x="${W / 2}" y="${H * 0.925}" font-family="${FONT}" font-size="${s}" fill="${muted}" text-anchor="middle">${esc(o.footer)}</text>`)
91
+ }
92
+ return `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>`
93
+ }
94
+
95
+ function run(cmd: string, args: string[]): Promise<{ code: number; err: string }> {
96
+ return new Promise((resolve) => {
97
+ const p = spawn(cmd, args)
98
+ let err = ''
99
+ p.stderr.on('data', (d) => { err += d.toString() })
100
+ p.on('error', (e) => resolve({ code: 1, err: String(e) }))
101
+ p.on('close', (code) => resolve({ code: code ?? 1, err }))
102
+ })
103
+ }
104
+
105
+ async function hasFfmpeg(): Promise<boolean> {
106
+ const { code } = await run('ffmpeg', ['-version'])
107
+ return code === 0
108
+ }
109
+
110
+ export async function makeSlideshow(opts: SlideshowOpts): Promise<{ out: string; pages: number; seconds: number }> {
111
+ if (!opts.images.length) throw new Error('没有输入图片')
112
+ if (!(await hasFfmpeg())) throw new Error('需要 ffmpeg(brew install ffmpeg)')
113
+
114
+ const sharpMod = await import('sharp').catch(() => null)
115
+ if (!sharpMod) throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli')
116
+ const sharp = (sharpMod as any).default ?? sharpMod
117
+
118
+ const W = opts.width ?? 1080
119
+ const H = opts.height ?? 1920
120
+ const ART = opts.artSize ?? 520
121
+ const sec = opts.seconds ?? 2.5
122
+ const theme = opts.theme ?? 'light'
123
+ const work = await mkdtemp(join(tmpdir(), 'museav-slideshow-'))
124
+
125
+ try {
126
+ const pages: string[] = []
127
+ for (let i = 0; i < opts.images.length; i++) {
128
+ const bg = Buffer.from(pageSvg({
129
+ width: W, height: H, theme,
130
+ title: opts.title, subtitle: opts.subtitle,
131
+ caption: opts.captions?.[i], footer: opts.footer,
132
+ }))
133
+ // 贴纸类图片四周常有大片透明留白,直接放大等于把留白也放大、主体显小。
134
+ // 先裁到主体外接框再放大,画面才撑得住。只对带 alpha 的图做——
135
+ // 对不透明照片 trim 会去裁纯色边框,那不是这里想要的。
136
+ let pipe = sharp(opts.images[i])
137
+ if (opts.trim !== false && (await pipe.metadata()).hasAlpha) {
138
+ pipe = sharp(opts.images[i]).trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
139
+ }
140
+ // 显式等比 resize 到 ART:小图必须放大,否则在竖屏里小得可怜
141
+ const art = await pipe
142
+ .resize(ART, ART, { fit: 'inside', withoutEnlargement: false, background: { r: 0, g: 0, b: 0, alpha: 0 } })
143
+ .png()
144
+ .toBuffer({ resolveWithObject: true })
145
+ const top = Math.round(H * 0.344 + (H * 0.365 - art.info.height) / 2)
146
+ const left = Math.round((W - art.info.width) / 2)
147
+ const page = join(work, `p${String(i).padStart(3, '0')}.png`)
148
+ await sharp(bg).composite([{ input: art.data, top: Math.max(0, top), left: Math.max(0, left) }]).png().toFile(page)
149
+ pages.push(page)
150
+ }
151
+
152
+ // concat demuxer:末页要重复列一次,否则它的 duration 会被忽略
153
+ const listLines: string[] = []
154
+ for (const p of pages) listLines.push(`file '${p}'`, `duration ${sec}`)
155
+ listLines.push(`file '${pages[pages.length - 1]}'`)
156
+ const listFile = join(work, 'list.txt')
157
+ await writeFile(listFile, listLines.join('\n') + '\n', 'utf8')
158
+
159
+ const total = pages.length * sec
160
+ const args = ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile]
161
+ if (opts.music) args.push('-i', opts.music)
162
+ args.push('-c:v', 'libx264', '-r', '30', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '22')
163
+ if (opts.music) {
164
+ const fadeOut = Math.max(0, total - 1.5)
165
+ args.push('-af', `atrim=0:${total},afade=t=in:st=0:d=1,afade=t=out:st=${fadeOut}:d=1.5`,
166
+ '-c:a', 'aac', '-b:a', '128k', '-shortest')
167
+ }
168
+ args.push(opts.out)
169
+
170
+ const { code, err } = await run('ffmpeg', args)
171
+ if (code !== 0) throw new Error(`ffmpeg 失败:${err.trim().slice(0, 400)}`)
172
+ return { out: opts.out, pages: pages.length, seconds: total }
173
+ } finally {
174
+ await rm(work, { recursive: true, force: true }).catch(() => {})
175
+ }
176
+ }
177
+
178
+ export async function fileSize(p: string): Promise<number> {
179
+ try {
180
+ return (await stat(p)).size
181
+ } catch {
182
+ return 0
183
+ }
184
+ }