museav-cli 2.8.0 → 2.10.1

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.
@@ -0,0 +1,210 @@
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
+ }
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, slideshowLayoutsCmd, createSlideshowLayoutCmd } 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'
@@ -26,6 +27,7 @@ import { videoTemplates, createVideoTemplate } from './commands/video-templates.
26
27
  import { balance } from './commands/balance.js'
27
28
  import { jobs } from './commands/jobs.js'
28
29
  import { whoami } from './commands/whoami.js'
30
+ import { feedback } from './commands/feedback.js'
29
31
  import { products } from './commands/products.js'
30
32
  import { assets } from './commands/assets.js'
31
33
  import { stickers, createSticker } from './commands/stickers.js'
@@ -210,6 +212,37 @@ program
210
212
  .option('--overwrite', '允许覆盖已存在的输出文件')
211
213
  .action(asyncRun((input: string, opts: any) => removeWatermarkCmd(input, opts)))
212
214
 
215
+ program
216
+ .command('slideshow <图片或目录...>')
217
+ .description('本地图集转竖版短视频(sharp + ffmpeg,免登录):默认 1080×1920 / 30fps / H.264,可加标题、逐张文案与配乐。需要本机有 ffmpeg')
218
+ .option('--out <path>', '输出 mp4(默认当前目录 slideshow.mp4)')
219
+ .option('--title <text>', '顶部主标题(大字)')
220
+ .option('--subtitle <text>', '顶部副标题(小字)')
221
+ .option('--caption <text>', '单页文案,可重复传,按顺序对应每张图', (v: string, acc: string[]) => [...acc, v], [] as string[])
222
+ .option('--captions <file>', '文案文件(每行一条,按顺序对应每张图),与 --caption 二选一')
223
+ .option('--footer <text>', '底部引导语')
224
+ .option('--music <file>', '配乐音频。自动裁到视频长度并加首尾淡入淡出(不会中途硬切断)')
225
+ .option('--sec <n>', '每张停留秒数,默认取模板的(内置模板 2.5)')
226
+ .option('--size <WxH>', '画面尺寸,默认取模板画布(内置模板 1080x1920);与模板不同则整体等比缩放')
227
+ .option('--theme <name>', 'light(默认)/ dark。只影响内置版式,给了 --layout 就以模板为准')
228
+ .option('--layout <slug>', '排版模板 slug:内置的直接用,其余去中台拉(需登录)。清单见 museav slideshow-layouts')
229
+ .option('--layout-file <path>', '直接用本地 JSON 排版模板(调模板时用,免登录)')
230
+ .action(asyncRun((paths: string[], opts: any) => slideshowCmd(paths, opts)))
231
+
232
+ const slideshowLayoutsCommand = program
233
+ .command('slideshow-layouts')
234
+ .description('查排版模板:内置版式(免登录)+ 中台模板(平台共享 / 本租户 / 本人私有)。配合 slideshow --layout 使用')
235
+ .action(asyncRun(() => slideshowLayoutsCmd()))
236
+
237
+ slideshowLayoutsCommand
238
+ .command('create <layout.json>')
239
+ .description('从 JSON 文件新建排版模板——归属由身份自动决定:租户 Key 建的归本租户,个人账号建的仅本人可见。结构校验在服务端做')
240
+ .requiredOption('--name <名称>', '模板中文名')
241
+ .requiredOption('--slug <slug>', '对外调用标识(小写字母/数字/连字符,全局唯一)')
242
+ .option('--description <text>', '模板说明')
243
+ .option('--category <name>', '分类,默认「其他」')
244
+ .action(asyncRun((file: string, opts: any) => createSlideshowLayoutCmd(file, opts)))
245
+
213
246
  // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
214
247
  const projectsCmd = program
215
248
  .command('projects')
@@ -398,6 +431,13 @@ program
398
431
  .description('查当前登录账户 + 租户归属(仅个人 login 可用,apiKey 调用会报错)')
399
432
  .action(withClient((client: StudioClient) => whoami(client)))
400
433
 
434
+ program
435
+ .command('feedback [content]')
436
+ .description('提 bug / 需求(不带内容则交互式输入);--list 看我的反馈记录。个人账户可提,租户 apiKey 会被服务端拒')
437
+ .option('--type <type>', '反馈类型:bug / 需求(默认 bug)')
438
+ .option('--list', '列出我的反馈记录')
439
+ .action(withLazyClient((getClient: () => StudioClient, content: string | undefined, opts: any) => feedback(getClient, content || '', opts)))
440
+
401
441
  program
402
442
  .command('config')
403
443
  .description('配置中台地址和 apiKey(存到 ~/.museav.json)')
@@ -0,0 +1,299 @@
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
+
32
+ /** 图层类型。与中台 shared/slideshow-layout.js 的 LAYER_TYPES 对应。 */
33
+ export type LayerType = 'rect' | 'ellipse' | 'text' | 'slot' | 'image'
34
+
35
+ export interface Layer {
36
+ type: LayerType | string
37
+ fill?: string
38
+ opacity?: number
39
+ // rect / slot / image
40
+ box?: [number, number, number, number]
41
+ radius?: number
42
+ fit?: 'contain' | 'cover'
43
+ trim?: boolean
44
+ src?: string
45
+ // ellipse
46
+ cx?: number; cy?: number; rx?: number; ry?: number
47
+ // text
48
+ bind?: string | null
49
+ text?: string
50
+ x?: number; y?: number
51
+ size?: number
52
+ weight?: number
53
+ align?: 'left' | 'center' | 'right'
54
+ maxWidth?: number
55
+ minSize?: number
56
+ }
57
+
58
+ export interface Layout {
59
+ version?: number
60
+ canvas: { w: number; h: number; bg?: string }
61
+ seconds?: number
62
+ layers: Layer[]
63
+ }
64
+
65
+ /** 渲染时填进模板的内容。caption / image 逐页变化,其余全局固定。 */
66
+ export interface PageContent {
67
+ title?: string
68
+ subtitle?: string
69
+ caption?: string
70
+ footer?: string
71
+ image: string
72
+ }
73
+
74
+ export interface SlideshowOpts {
75
+ images: string[]
76
+ out: string
77
+ layout: Layout
78
+ captions?: string[]
79
+ title?: string
80
+ subtitle?: string
81
+ footer?: string
82
+ music?: string
83
+ seconds?: number
84
+ /** 目标尺寸。与模板 canvas 不同时整个版面按比例缩放 */
85
+ width?: number
86
+ height?: number
87
+ /** 解析 sticker://<id> → 图片 URL/本地路径。不传则跳过这类图层 */
88
+ resolveSticker?: (id: string) => Promise<string | null>
89
+ onWarn?: (msg: string) => void
90
+ }
91
+
92
+ const FONT = 'Hiragino Sans GB, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif'
93
+
94
+ function esc(s: string): string {
95
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
96
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
97
+ }
98
+
99
+ /** 中文按全宽算、ASCII 按半宽算,估出一行文字的像素宽,用来收敛字号 */
100
+ function textWidth(s: string, size: number): number {
101
+ let w = 0
102
+ for (const ch of s) w += /[一-鿿 -〿＀-￯]/.test(ch) ? size : size * 0.55
103
+ return w
104
+ }
105
+
106
+ function fitSize(text: string, max: number, start: number, min: number): number {
107
+ let size = start
108
+ while (size > min && textWidth(text, size) > max) size -= 2
109
+ return size
110
+ }
111
+
112
+ /** 取 text 层这一页该显示的文字:有 bind 就取绑定值,否则用固定 text */
113
+ function resolveText(layer: Layer, content: PageContent): string {
114
+ if (!layer.bind) return layer.text ?? ''
115
+ const v = (content as unknown as Record<string, unknown>)[layer.bind]
116
+ return typeof v === 'string' ? v : ''
117
+ }
118
+
119
+ /**
120
+ * 把一页渲染成 SVG(不含图片 —— 图片走 sharp composite,SVG 里嵌 base64 会让文件爆大)。
121
+ * 返回 SVG 字符串 + 需要 composite 的图片层。
122
+ */
123
+ function buildPage(layout: Layout, content: PageContent, scale: number, onWarn?: (m: string) => void) {
124
+ const W = Math.round(layout.canvas.w * scale)
125
+ const H = Math.round(layout.canvas.h * scale)
126
+ const s = (n: number) => n * scale
127
+ const parts: string[] = [`<rect width="${W}" height="${H}" fill="${layout.canvas.bg ?? '#ffffff'}"/>`]
128
+ const images: { layer: Layer; box: [number, number, number, number] }[] = []
129
+ const unknown = new Set<string>()
130
+
131
+ for (const layer of layout.layers) {
132
+ const op = layer.opacity !== undefined ? ` opacity="${layer.opacity}"` : ''
133
+
134
+ if (layer.type === 'rect' && layer.box) {
135
+ const [x, y, w, h] = layer.box
136
+ const r = layer.radius ? ` rx="${s(layer.radius)}"` : ''
137
+ parts.push(`<rect x="${s(x)}" y="${s(y)}" width="${s(w)}" height="${s(h)}"${r} fill="${layer.fill ?? '#000000'}"${op}/>`)
138
+ } else if (layer.type === 'ellipse') {
139
+ 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}/>`)
140
+ } else if (layer.type === 'text') {
141
+ const txt = resolveText(layer, content)
142
+ // 绑定值为空就整层不画(比如没给 --subtitle),而不是画一行空白占位
143
+ if (!txt) continue
144
+ const base = s(layer.size ?? 40)
145
+ const min = s(layer.minSize ?? Math.max(12, (layer.size ?? 40) * 0.5))
146
+ const max = (layer.maxWidth ?? 0.86) * W
147
+ const size = fitSize(txt, max, base, min)
148
+ const anchor = layer.align === 'left' ? 'start' : layer.align === 'right' ? 'end' : 'middle'
149
+ const weight = layer.weight ? ` font-weight="${layer.weight}"` : ''
150
+ parts.push(
151
+ `<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>`,
152
+ )
153
+ } else if ((layer.type === 'slot' || layer.type === 'image') && layer.box) {
154
+ const [x, y, w, h] = layer.box
155
+ images.push({ layer, box: [s(x), s(y), s(w), s(h)] })
156
+ } else {
157
+ // 不认识的层类型:跳过,不中断。老 CLI 碰上新层类型该少画一层,不是出不了片。
158
+ unknown.add(String(layer.type))
159
+ }
160
+ }
161
+
162
+ if (unknown.size && onWarn) {
163
+ onWarn(`模板里有本版本不认识的图层类型(${[...unknown].join(', ')}),已跳过。升级试试:npm i -g museav-cli`)
164
+ }
165
+ return { svg: `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>`, W, H, images }
166
+ }
167
+
168
+ function run(cmd: string, args: string[]): Promise<{ code: number; err: string }> {
169
+ return new Promise((resolve) => {
170
+ const p = spawn(cmd, args)
171
+ let err = ''
172
+ p.stderr.on('data', (d) => { err += d.toString() })
173
+ p.on('error', (e) => resolve({ code: 1, err: String(e) }))
174
+ p.on('close', (code) => resolve({ code: code ?? 1, err }))
175
+ })
176
+ }
177
+
178
+ async function hasFfmpeg(): Promise<boolean> {
179
+ const { code } = await run('ffmpeg', ['-version'])
180
+ return code === 0
181
+ }
182
+
183
+ export async function makeSlideshow(opts: SlideshowOpts): Promise<{ out: string; pages: number; seconds: number }> {
184
+ if (!opts.images.length) throw new Error('没有输入图片')
185
+ if (!(await hasFfmpeg())) throw new Error('需要 ffmpeg(brew install ffmpeg)')
186
+
187
+ const sharpMod = await import('sharp').catch(() => null)
188
+ if (!sharpMod) throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli')
189
+ const sharp = (sharpMod as any).default ?? sharpMod
190
+
191
+ const layout = opts.layout
192
+ // 目标尺寸与模板 canvas 不同就整体等比缩放。取较小的比例,保证画面不被裁掉。
193
+ const targetW = opts.width ?? layout.canvas.w
194
+ const targetH = opts.height ?? layout.canvas.h
195
+ const scale = Math.min(targetW / layout.canvas.w, targetH / layout.canvas.h)
196
+ const sec = opts.seconds ?? layout.seconds ?? 2.5
197
+ const work = await mkdtemp(join(tmpdir(), 'museav-slideshow-'))
198
+
199
+ // sticker:// 引用逐个解析一次就够,同一个贴图在每页都用得上
200
+ const stickerCache = new Map<string, string | null>()
201
+ let warned = false
202
+ const warn = (m: string) => { if (!warned) { warned = true; opts.onWarn?.(m) } }
203
+
204
+ try {
205
+ const pages: string[] = []
206
+ for (let i = 0; i < opts.images.length; i++) {
207
+ const content: PageContent = {
208
+ title: opts.title,
209
+ subtitle: opts.subtitle,
210
+ caption: opts.captions?.[i],
211
+ footer: opts.footer,
212
+ image: opts.images[i],
213
+ }
214
+ const { svg, images } = buildPage(layout, content, scale, warn)
215
+ const composites: { input: Buffer; top: number; left: number }[] = []
216
+
217
+ for (const { layer, box } of images) {
218
+ const [bx, by, bw, bh] = box
219
+ let src: string | null = null
220
+
221
+ if (layer.type === 'slot') {
222
+ src = content.image
223
+ } else if (layer.src?.startsWith('sticker://')) {
224
+ const id = layer.src.slice('sticker://'.length)
225
+ if (!stickerCache.has(id)) stickerCache.set(id, opts.resolveSticker ? await opts.resolveSticker(id) : null)
226
+ src = stickerCache.get(id) ?? null
227
+ if (!src) { warn(`模板引用的贴图 ${id} 取不到,已跳过该图层`); continue }
228
+ } else {
229
+ src = layer.src ?? null
230
+ }
231
+ if (!src) continue
232
+
233
+ let pipe = sharp(src.startsWith('http') ? Buffer.from(await (await fetch(src)).arrayBuffer()) : src)
234
+ // 贴纸类素材四周常有大片透明留白,直接放大等于把留白也放大、主体显小。
235
+ // 只对带 alpha 的图做 —— 对不透明照片 trim 会去裁纯色边框,那不是这里想要的。
236
+ if (layer.trim && (await pipe.metadata()).hasAlpha) {
237
+ const buf = await pipe.toBuffer()
238
+ pipe = sharp(buf).trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
239
+ }
240
+ // 显式放大:fit inside + withoutEnlargement:false,小图必须撑满槽位。
241
+ //
242
+ // 注意 box 的语义是**矩形框 + contain**(同 CSS object-fit),不是「正方形边长」。
243
+ // 2.9.0 那版把主图区当成正方形(resize(ART, ART)),对高瘦的图会受高度限制而偏小:
244
+ // 219×229 的图在 518×701 的框里,旧算法出 495×518,新算法出 518×542(大 4.6%)。
245
+ // 换成框语义是刻意的——框多大图就能占多大,这才符合直觉,也是主图偏小那个老问题的根治。
246
+ const art = await pipe
247
+ .resize(Math.round(bw), Math.round(bh), {
248
+ fit: layer.fit === 'cover' ? 'cover' : 'inside',
249
+ withoutEnlargement: false,
250
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
251
+ })
252
+ .png()
253
+ .toBuffer({ resolveWithObject: true })
254
+ // 在槽位内居中
255
+ composites.push({
256
+ input: art.data,
257
+ top: Math.max(0, Math.round(by + (bh - art.info.height) / 2)),
258
+ left: Math.max(0, Math.round(bx + (bw - art.info.width) / 2)),
259
+ })
260
+ }
261
+
262
+ const page = join(work, `p${String(i).padStart(3, '0')}.png`)
263
+ await sharp(Buffer.from(svg)).composite(composites).png().toFile(page)
264
+ pages.push(page)
265
+ }
266
+
267
+ // concat demuxer:末页要重复列一次,否则它的 duration 会被忽略
268
+ const listLines: string[] = []
269
+ for (const p of pages) listLines.push(`file '${p}'`, `duration ${sec}`)
270
+ listLines.push(`file '${pages[pages.length - 1]}'`)
271
+ const listFile = join(work, 'list.txt')
272
+ await writeFile(listFile, listLines.join('\n') + '\n', 'utf8')
273
+
274
+ const total = pages.length * sec
275
+ const args = ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile]
276
+ if (opts.music) args.push('-i', opts.music)
277
+ args.push('-c:v', 'libx264', '-r', '30', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '22')
278
+ if (opts.music) {
279
+ const fadeOut = Math.max(0, total - 1.5)
280
+ args.push('-af', `atrim=0:${total},afade=t=in:st=0:d=1,afade=t=out:st=${fadeOut}:d=1.5`,
281
+ '-c:a', 'aac', '-b:a', '128k', '-shortest')
282
+ }
283
+ args.push(opts.out)
284
+
285
+ const { code, err } = await run('ffmpeg', args)
286
+ if (code !== 0) throw new Error(`ffmpeg 失败:${err.trim().slice(0, 400)}`)
287
+ return { out: opts.out, pages: pages.length, seconds: total }
288
+ } finally {
289
+ await rm(work, { recursive: true, force: true }).catch(() => {})
290
+ }
291
+ }
292
+
293
+ export async function fileSize(p: string): Promise<number> {
294
+ try {
295
+ return (await stat(p)).size
296
+ } catch {
297
+ return 0
298
+ }
299
+ }
@@ -0,0 +1,42 @@
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
+
14
+ export const PRESET_LIGHT: Layout = {
15
+ version: 1,
16
+ canvas: { w: 1080, h: 1920, bg: '#ffffff' },
17
+ seconds: 2.5,
18
+ layers: [
19
+ { type: 'ellipse', cx: 190, cy: -131, rx: 510, ry: 430, fill: '#ffd6e2' },
20
+ { type: 'ellipse', cx: 1110, cy: 1901, rx: 291, ry: 280, fill: '#baebe2' },
21
+ { type: 'text', bind: 'title', x: 540, y: 394, size: 84, weight: 600, fill: '#2b2d31', align: 'center', maxWidth: 0.86, minSize: 36 },
22
+ { type: 'text', bind: 'subtitle', x: 540, y: 495, size: 44, fill: '#8c929b', align: 'center' },
23
+ { type: 'slot', bind: 'image', box: [281, 660, 518, 701], fit: 'contain', trim: true },
24
+ { type: 'text', bind: 'caption', x: 540, y: 1578, size: 72, weight: 600, fill: '#2b2d31', align: 'center', maxWidth: 0.86, minSize: 28 },
25
+ { type: 'text', bind: 'footer', x: 540, y: 1776, size: 33, fill: '#8c929b', align: 'center' },
26
+ ],
27
+ }
28
+
29
+ export const PRESET_DARK: Layout = {
30
+ ...PRESET_LIGHT,
31
+ canvas: { w: 1080, h: 1920, bg: '#16181c' },
32
+ layers: PRESET_LIGHT.layers.map((l) => {
33
+ if (l.type === 'ellipse') return { ...l, fill: l.fill === '#ffd6e2' ? '#3a2b33' : '#22383a' }
34
+ if (l.type === 'text') return { ...l, fill: l.fill === '#2b2d31' ? '#e9ebef' : '#8b929c' }
35
+ return l
36
+ }),
37
+ }
38
+
39
+ export const PRESETS: Record<string, Layout> = {
40
+ 'sticker-pink-mint': PRESET_LIGHT,
41
+ 'sticker-pink-mint-dark': PRESET_DARK,
42
+ }