museav-cli 2.9.0 → 2.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +33 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +32 -0
- package/dist/commands/feedback.d.ts +5 -0
- package/dist/commands/feedback.js +67 -0
- package/dist/commands/slideshow.d.ts +11 -2
- package/dist/commands/slideshow.js +115 -15
- package/dist/index.js +25 -6
- package/dist/local-slideshow.d.ts +49 -6
- package/dist/local-slideshow.js +166 -57
- package/dist/slideshow-presets.d.ts +15 -0
- package/dist/slideshow-presets.js +29 -0
- package/package.json +1 -1
- package/src/client.ts +40 -0
- package/src/commands/feedback.ts +75 -0
- package/src/commands/slideshow.ts +123 -16
- package/src/index.ts +28 -6
- package/src/local-slideshow.ts +213 -65
- package/src/slideshow-presets.ts +42 -0
package/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>', '
|
|
225
|
-
.option('--size <WxH>', '
|
|
226
|
-
.option('--
|
|
227
|
-
.option('--
|
|
228
|
-
.option('--
|
|
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)')
|
package/src/local-slideshow.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 本地图集转竖版短视频 —— slideshow 的核心实现。
|
|
3
|
-
* 一组图 + 每张的文字说明 + 配乐 →
|
|
3
|
+
* 一组图 + 每张的文字说明 + 配乐 → mp4,适合发朋友圈 / 视频号 / 小红书。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
180
|
+
flush()
|
|
181
|
+
|
|
182
|
+
if (unknown.size && onWarn) {
|
|
183
|
+
onWarn(`模板里有本版本不认识的图层类型(${[...unknown].join(', ')}),已跳过。升级试试:npm i -g museav-cli`)
|
|
91
184
|
}
|
|
92
|
-
return
|
|
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
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
caption: opts.captions?.[i],
|
|
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
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
+
}
|