museav-cli 2.9.0 → 2.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ import { printWelcome } from './commands/welcome.js'
16
16
  import { gen } from './commands/gen.js'
17
17
  import { reverse } from './commands/reverse.js'
18
18
  import { compressCmd, removeBgCmd, upscaleCmd, removeWatermarkCmd } from './commands/img-tools.js'
19
- import { slideshowCmd } from './commands/slideshow.js'
19
+ import { slideshowCmd, slideshowLayoutsCmd, createSlideshowLayoutCmd } from './commands/slideshow.js'
20
20
  import { projects, createProject, listAssets, addAsset, removeAsset, resolveWorkspace } from './commands/projects.js'
21
21
  import { imageToTemplate } from './commands/image-to-template.js'
22
22
  import { upload } from './commands/upload.js'
@@ -27,6 +27,7 @@ import { videoTemplates, createVideoTemplate } from './commands/video-templates.
27
27
  import { balance } from './commands/balance.js'
28
28
  import { jobs } from './commands/jobs.js'
29
29
  import { whoami } from './commands/whoami.js'
30
+ import { feedback } from './commands/feedback.js'
30
31
  import { products } from './commands/products.js'
31
32
  import { assets } from './commands/assets.js'
32
33
  import { stickers, createSticker } from './commands/stickers.js'
@@ -221,13 +222,27 @@ program
221
222
  .option('--captions <file>', '文案文件(每行一条,按顺序对应每张图),与 --caption 二选一')
222
223
  .option('--footer <text>', '底部引导语')
223
224
  .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', '不裁主图四周的透明留白(默认会裁,贴纸类图片不裁会显得很小)')
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 排版模板(调模板时用,免登录)')
229
230
  .action(asyncRun((paths: string[], opts: any) => slideshowCmd(paths, opts)))
230
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
+
231
246
  // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
232
247
  const projectsCmd = program
233
248
  .command('projects')
@@ -416,6 +431,13 @@ program
416
431
  .description('查当前登录账户 + 租户归属(仅个人 login 可用,apiKey 调用会报错)')
417
432
  .action(withClient((client: StudioClient) => whoami(client)))
418
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
+
419
441
  program
420
442
  .command('config')
421
443
  .description('配置中台地址和 apiKey(存到 ~/.museav.json)')
@@ -1,14 +1,25 @@
1
1
  /**
2
2
  * 本地图集转竖版短视频 —— slideshow 的核心实现。
3
- * 一组图 + 每张的文字说明 + 配乐 → 1080×1920 的 mp4,适合发朋友圈 / 视频号 / 小红书。
3
+ * 一组图 + 每张的文字说明 + 配乐 → mp4,适合发朋友圈 / 视频号 / 小红书。
4
4
  *
5
- * 排版走 sharp 渲染 SVG(中文靠系统字体,macOS/Win/主流 Linux 都有中文字体),
6
- * 合成走 ffmpeg concat demuxer —— 不引入 canvas 那种要编译的重依赖。
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 那种要编译的重依赖。
7
17
  *
8
18
  * 三个实测踩出来的点,写在这里免得下次重踩:
9
19
  *
10
20
  * 1. 图片必须显式放大。小图(如 240×240 的表情)直接贴到 1080 宽的画布上只占两成宽,
11
21
  * 画面空得离谱。要按目标尺寸等比 resize,而不是「不超过某上限」那种只缩不放的逻辑。
22
+ * 贴纸类素材还要先裁掉四周透明留白(slot 层的 trim),否则放大的是留白、主体照样小。
12
23
  * 2. 配乐不能直接 -shortest。配乐通常比视频长几倍,硬切会在中途断掉。
13
24
  * 要 atrim 裁到视频时长 + 首尾 afade。
14
25
  * 3. concat demuxer 的最后一张要再列一次,否则它的 duration 被忽略,末页一闪而过。
@@ -18,22 +29,64 @@ import { tmpdir } from 'node:os'
18
29
  import { join } from 'node:path'
19
30
  import { spawn } from 'node:child_process'
20
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
+
21
74
  export interface SlideshowOpts {
22
75
  images: string[]
23
76
  out: string
77
+ layout: Layout
78
+ captions?: string[]
24
79
  title?: string
25
80
  subtitle?: string
26
- captions?: string[]
27
81
  footer?: string
28
82
  music?: string
29
83
  seconds?: number
84
+ /** 目标尺寸。与模板 canvas 不同时整个版面按比例缩放 */
30
85
  width?: number
31
86
  height?: number
32
- /** 主图渲染尺寸(正方形边长)。默认 520,约占 1080 宽的 48% */
33
- artSize?: number
34
- theme?: 'light' | 'dark'
35
- /** 放大前先裁掉主图四周的透明留白,默认开(仅对带 alpha 的图生效) */
36
- trim?: boolean
87
+ /** 解析 sticker://<id> 图片 URL/本地路径。不传则跳过这类图层 */
88
+ resolveSticker?: (id: string) => Promise<string | null>
89
+ onWarn?: (msg: string) => void
37
90
  }
38
91
 
39
92
  const FONT = 'Hiragino Sans GB, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif'
@@ -50,46 +103,86 @@ function textWidth(s: string, size: number): number {
50
103
  return w
51
104
  }
52
105
 
53
- function fitSize(text: string, max: number, start: number, min = 24): number {
106
+ function fitSize(text: string, max: number, start: number, min: number): number {
54
107
  let size = start
55
108
  while (size > min && textWidth(text, size) > max) size -= 2
56
109
  return size
57
110
  }
58
111
 
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>`)
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
+ /** 渲染步骤:一段矢量图层(SVG)或一个图片图层。**顺序就是 DSL layers 的顺序。** */
120
+ type Step =
121
+ | { kind: 'svg'; svg: string }
122
+ | { kind: 'image'; layer: Layer; box: [number, number, number, number] }
123
+
124
+ /**
125
+ * 把一页拆成有序的渲染步骤。
126
+ *
127
+ * 关键点:**不能把图片层统一提到最后 composite**。sharp composite 一律贴在底图之上,
128
+ * 那样所有图片就永远压在所有矢量层上面,DSL 里的图层顺序当场失效 ——
129
+ * 「图片铺满 + 文字压在上面」这种版式会渲染成文字被图片盖住(实测踩到)。
130
+ * 所以这里把连续的矢量层攒成一段 SVG,遇到图片层就断开,按原顺序逐段叠。
131
+ *
132
+ * 图片不嵌进 SVG 是另一回事:base64 内联会让 SVG 爆大且慢,走 composite 更划算。
133
+ */
134
+ function buildPage(layout: Layout, content: PageContent, scale: number, onWarn?: (m: string) => void) {
135
+ const W = Math.round(layout.canvas.w * scale)
136
+ const H = Math.round(layout.canvas.h * scale)
137
+ const s = (n: number) => n * scale
138
+ const steps: Step[] = []
139
+ let parts: string[] = []
140
+ const unknown = new Set<string>()
141
+
142
+ // 攒够的矢量层收成一段 SVG(透明底 —— 背景色由 base 图承担,否则后一段会盖掉前一段)
143
+ const flush = () => {
144
+ if (!parts.length) return
145
+ steps.push({ kind: 'svg', svg: `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>` })
146
+ parts = []
83
147
  }
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>`)
148
+
149
+ for (const layer of layout.layers) {
150
+ const op = layer.opacity !== undefined ? ` opacity="${layer.opacity}"` : ''
151
+
152
+ if (layer.type === 'rect' && layer.box) {
153
+ const [x, y, w, h] = layer.box
154
+ const r = layer.radius ? ` rx="${s(layer.radius)}"` : ''
155
+ parts.push(`<rect x="${s(x)}" y="${s(y)}" width="${s(w)}" height="${s(h)}"${r} fill="${layer.fill ?? '#000000'}"${op}/>`)
156
+ } else if (layer.type === 'ellipse') {
157
+ 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}/>`)
158
+ } else if (layer.type === 'text') {
159
+ const txt = resolveText(layer, content)
160
+ // 绑定值为空就整层不画(比如没给 --subtitle),而不是画一行空白占位
161
+ if (!txt) continue
162
+ const base = s(layer.size ?? 40)
163
+ const min = s(layer.minSize ?? Math.max(12, (layer.size ?? 40) * 0.5))
164
+ const max = (layer.maxWidth ?? 0.86) * W
165
+ const size = fitSize(txt, max, base, min)
166
+ const anchor = layer.align === 'left' ? 'start' : layer.align === 'right' ? 'end' : 'middle'
167
+ const weight = layer.weight ? ` font-weight="${layer.weight}"` : ''
168
+ parts.push(
169
+ `<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>`,
170
+ )
171
+ } else if ((layer.type === 'slot' || layer.type === 'image') && layer.box) {
172
+ const [x, y, w, h] = layer.box
173
+ flush() // 断开:此前的矢量层要落在这张图**下面**
174
+ steps.push({ kind: 'image', layer, box: [s(x), s(y), s(w), s(h)] })
175
+ } else {
176
+ // 不认识的层类型:跳过,不中断。老 CLI 碰上新层类型该少画一层,不是出不了片。
177
+ unknown.add(String(layer.type))
178
+ }
87
179
  }
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>`)
180
+ flush()
181
+
182
+ if (unknown.size && onWarn) {
183
+ onWarn(`模板里有本版本不认识的图层类型(${[...unknown].join(', ')}),已跳过。升级试试:npm i -g museav-cli`)
91
184
  }
92
- return `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>`
185
+ return { steps, W, H }
93
186
  }
94
187
 
95
188
  function run(cmd: string, args: string[]): Promise<{ code: number; err: string }> {
@@ -115,37 +208,92 @@ export async function makeSlideshow(opts: SlideshowOpts): Promise<{ out: string;
115
208
  if (!sharpMod) throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli')
116
209
  const sharp = (sharpMod as any).default ?? sharpMod
117
210
 
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'
211
+ const layout = opts.layout
212
+ // 目标尺寸与模板 canvas 不同就整体等比缩放。取较小的比例,保证画面不被裁掉。
213
+ const targetW = opts.width ?? layout.canvas.w
214
+ const targetH = opts.height ?? layout.canvas.h
215
+ const scale = Math.min(targetW / layout.canvas.w, targetH / layout.canvas.h)
216
+ const sec = opts.seconds ?? layout.seconds ?? 2.5
123
217
  const work = await mkdtemp(join(tmpdir(), 'museav-slideshow-'))
124
218
 
219
+ // sticker:// 引用逐个解析一次就够,同一个贴图在每页都用得上
220
+ const stickerCache = new Map<string, string | null>()
221
+ let warned = false
222
+ const warn = (m: string) => { if (!warned) { warned = true; opts.onWarn?.(m) } }
223
+
125
224
  try {
126
225
  const pages: string[] = []
127
226
  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 })
227
+ const content: PageContent = {
228
+ title: opts.title,
229
+ subtitle: opts.subtitle,
230
+ caption: opts.captions?.[i],
231
+ footer: opts.footer,
232
+ image: opts.images[i],
139
233
  }
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)
234
+ const { steps, W, H } = buildPage(layout, content, scale, warn)
235
+ const composites: { input: Buffer; top: number; left: number }[] = []
236
+
237
+ for (const step of steps) {
238
+ // 矢量段按原顺序插进来,图片压在它之前的段之上、之后的段之下
239
+ if (step.kind === 'svg') {
240
+ composites.push({ input: Buffer.from(step.svg), top: 0, left: 0 })
241
+ continue
242
+ }
243
+ const { layer, box } = step
244
+ const [bx, by, bw, bh] = box
245
+ let src: string | null = null
246
+
247
+ if (layer.type === 'slot') {
248
+ src = content.image
249
+ } else if (layer.src?.startsWith('sticker://')) {
250
+ const id = layer.src.slice('sticker://'.length)
251
+ if (!stickerCache.has(id)) stickerCache.set(id, opts.resolveSticker ? await opts.resolveSticker(id) : null)
252
+ src = stickerCache.get(id) ?? null
253
+ if (!src) { warn(`模板引用的贴图 ${id} 取不到,已跳过该图层`); continue }
254
+ } else {
255
+ src = layer.src ?? null
256
+ }
257
+ if (!src) continue
258
+
259
+ let pipe = sharp(src.startsWith('http') ? Buffer.from(await (await fetch(src)).arrayBuffer()) : src)
260
+ // 贴纸类素材四周常有大片透明留白,直接放大等于把留白也放大、主体显小。
261
+ // 只对带 alpha 的图做 —— 对不透明照片 trim 会去裁纯色边框,那不是这里想要的。
262
+ if (layer.trim && (await pipe.metadata()).hasAlpha) {
263
+ const buf = await pipe.toBuffer()
264
+ pipe = sharp(buf).trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
265
+ }
266
+ // 显式放大:fit inside + withoutEnlargement:false,小图必须撑满槽位。
267
+ //
268
+ // 注意 box 的语义是**矩形框 + contain**(同 CSS object-fit),不是「正方形边长」。
269
+ // 2.9.0 那版把主图区当成正方形(resize(ART, ART)),对高瘦的图会受高度限制而偏小:
270
+ // 219×229 的图在 518×701 的框里,旧算法出 495×518,新算法出 518×542(大 4.6%)。
271
+ // 换成框语义是刻意的——框多大图就能占多大,这才符合直觉,也是主图偏小那个老问题的根治。
272
+ const art = await pipe
273
+ .resize(Math.round(bw), Math.round(bh), {
274
+ fit: layer.fit === 'cover' ? 'cover' : 'inside',
275
+ withoutEnlargement: false,
276
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
277
+ })
278
+ .png()
279
+ .toBuffer({ resolveWithObject: true })
280
+ // 在槽位内居中
281
+ composites.push({
282
+ input: art.data,
283
+ top: Math.max(0, Math.round(by + (bh - art.info.height) / 2)),
284
+ left: Math.max(0, Math.round(bx + (bw - art.info.width) / 2)),
285
+ })
286
+ }
287
+
288
+ // 底图只负责画布背景色,所有内容(含第一段矢量层)都按顺序 composite 上去
147
289
  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)
290
+ const bg = layout.canvas.bg ?? '#ffffff'
291
+ await sharp({
292
+ create: { width: W, height: H, channels: 4, background: bg === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : bg },
293
+ })
294
+ .composite(composites)
295
+ .png()
296
+ .toFile(page)
149
297
  pages.push(page)
150
298
  }
151
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
+ }