karin-plugin-ffmpegrender 0.0.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.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # karin-plugin-ffmpegrender
2
+
3
+ 基于 `ffmpeg` 的 Karin 图片渲染器(渲染 ID:`ffmpeg`),通过 `*.ffrender.json` 描述图层合成并输出图片 base64。
4
+
5
+ ## 功能
6
+
7
+ - 图片渲染:本地图片 / http 图片 → base64
8
+ - 轻量排版:图片 + 文字(`drawtext`)+ 简单布局
9
+ - 模板渲染:支持 `renderTpl`(`data` 注入),模板渲染后会生成到 `@karinjs/temp/html/...`
10
+
11
+ ## 依赖
12
+
13
+ - 需要可用的 `ffmpeg`(PATH 或配置 `ffmpegPath`)
14
+ - 如需文字渲染,建议配置 `ffmpegFontFile`(或使用自动探测的系统字体)
15
+
16
+ ## 配置
17
+
18
+ 文件:`@karinjs/karin-plugin-ffmpegrender/config/config.json`
19
+
20
+ ```json
21
+ {
22
+ "ffmpegPath": "",
23
+ "ffmpegFontFile": "",
24
+ "ffmpegTimeoutMs": 30000,
25
+ "ffmpegLogCommand": false
26
+ }
27
+ ```
28
+
29
+ ## Demo 指令
30
+
31
+ - `#ffmpeg渲染`:基础示例(`resources/template/ffmpeg.ffrender.json`)
32
+ - `#ffmpeg排版`:图片 + 文字 + 简易排版(`resources/template/ffmpeg.card.ffrender.json`)
33
+
34
+ ## 代码调用
35
+
36
+ ```ts
37
+ const img = await render.render({
38
+ file: '/abs/path/to/template.ffrender.json',
39
+ type: 'png',
40
+ data: { file: '/abs/path/to/bg.png' }
41
+ }, 'ffmpeg')
42
+ ```
43
+
44
+ 更多说明见:`docs/ffmpeg-renderer.md`
45
+
@@ -0,0 +1,7 @@
1
+ {
2
+ "yiyanApi": "https://api.yujn.cn/api/sjyy.php",
3
+ "ffmpegPath": "",
4
+ "ffmpegFontFile": "",
5
+ "ffmpegTimeoutMs": 30000,
6
+ "ffmpegLogCommand": false
7
+ }
@@ -0,0 +1,41 @@
1
+ // src/apps/example.ts
2
+ import { karin, segment } from "node-karin";
3
+ var hello = karin.command("^(#)?\u4F60\u597D$", async (e) => {
4
+ await e.reply("\u4F60\u597D\u554A\uFF01\u6211\u662FKarin\uFF0C\u5F88\u9AD8\u5174\u8BA4\u8BC6\u4F60~ (\u3002\u30FB\u2200\u30FB)\u30CE", { at: false, recallMsg: 0, reply: true });
5
+ return true;
6
+ });
7
+ var test = karin.command("^(#)?\u6D4B\u8BD5$", "\u8BA9\u6211\u6765\u5C55\u793A\u4E00\u4E0B\u6211\u7684\u529F\u80FD\u5427\uFF01\u2728");
8
+ var text = karin.command("^(#)?\u6253\u62DB\u547C$", segment.text("\u5927\u5BB6\u597D\u5440\uFF01\u4ECA\u5929\u4E5F\u8981\u5143\u6C14\u6EE1\u6EE1\u54E6\uFF01\u2570(*\xB0\u25BD\xB0*)\u256F"), { name: "\u6253\u62DB\u547C" });
9
+ var test2 = karin.command("^(#)?\u83DC\u5355$", "\u6765\u770B\u770B\u6211\u90FD\u4F1A\u4E9B\u4EC0\u4E48\u5427~\n- #\u4F60\u597D\uFF1A\u6253\u4E2A\u62DB\u547C\n- #\u6D4B\u8BD5\uFF1A\u529F\u80FD\u5C55\u793A\n- #\u6253\u62DB\u547C\uFF1A\u5143\u6C14\u95EE\u5019\n(\uFF61\uFF65\u03C9\uFF65\uFF61)\uFF89\u2661", {
10
+ event: "message",
11
+ // 监听的事件
12
+ name: "\u83DC\u5355",
13
+ // 插件名称
14
+ perm: "all",
15
+ // 触发权限
16
+ at: false,
17
+ // 是否加上at 仅在群聊中有效
18
+ reply: false,
19
+ // 是否加上引用回复
20
+ recallMsg: 0,
21
+ // 发送是否撤回消息 单位秒
22
+ log: true,
23
+ // 是否启用日志
24
+ rank: 1e4,
25
+ // 优先级
26
+ adapter: [],
27
+ // 生效的适配器
28
+ dsbAdapter: [],
29
+ // 禁用的适配器
30
+ delay: 0,
31
+ // 延迟回复 单位毫秒 仅在第二个参数非函数时有效
32
+ stop: false,
33
+ // 是否停止执行后续插件 仅在第二个参数非函数时有效
34
+ authFailMsg: "\u54CE\u5440\uFF0C\u8FD9\u4E2A\u529F\u80FD\u53EA\u6709\u4E3B\u4EBA\u624D\u80FD\u7528\u54E6\uFF01\u8981\u4E0D\u4F60\u5148\u8BB8\u4E2A\u613F\uFF1F(\u0E51\u2022\u0300\u3142\u2022\u0301)\u0648\u2727"
35
+ });
36
+ export {
37
+ hello,
38
+ test,
39
+ test2,
40
+ text
41
+ };
@@ -0,0 +1,42 @@
1
+ import {
2
+ dir
3
+ } from "../chunk-NF24Q4FD.js";
4
+
5
+ // src/apps/ffmpegCardDemo.ts
6
+ import path from "path";
7
+ import { karin, render, segment, logger } from "node-karin";
8
+ var ffmpegCardDemo = karin.command(/^#?ffmpeg排版$/, async (e) => {
9
+ try {
10
+ const spec = path.join(dir.pluginDir, "resources", "template", "ffmpeg.card.ffrender.json");
11
+ const bg = path.join(dir.pluginDir, "resources", "image", "\u542F\u7A0B\u5BA3\u53D1.png");
12
+ const now = /* @__PURE__ */ new Date();
13
+ const footer = `user: ${e.user_id} \xB7 ${now.toLocaleString("zh-CN", { hour12: false })}`;
14
+ const img = await render.render({
15
+ name: "ffmpegrender",
16
+ file: spec,
17
+ type: "png",
18
+ data: {
19
+ file: bg,
20
+ title: "Karin FFmpeg \u6E32\u67D3",
21
+ subtitle: "\u56FE\u7247 + \u6587\u5B57 + \u7B80\u6613\u6392\u7248",
22
+ tag: "render id: ffmpeg",
23
+ desc: "spec(JSON) \u2192 filter_complex \u2192 base64\n\u652F\u6301\uFF1Acover/contain\u3001\u900F\u660E\u80CC\u666F\u3001\u6587\u5B57\u6846",
24
+ footer
25
+ }
26
+ }, "ffmpeg");
27
+ await e.reply(segment.image(`base64://${img}`));
28
+ return true;
29
+ } catch (error) {
30
+ logger.error(error);
31
+ await e.reply(String(error?.message || error));
32
+ return true;
33
+ }
34
+ }, {
35
+ name: "ffmpeg\u6392\u7248demo",
36
+ priority: 9999,
37
+ log: true,
38
+ permission: "all"
39
+ });
40
+ export {
41
+ ffmpegCardDemo
42
+ };
@@ -0,0 +1,33 @@
1
+ import {
2
+ dir
3
+ } from "../chunk-NF24Q4FD.js";
4
+
5
+ // src/apps/ffmpegDemo.ts
6
+ import path from "path";
7
+ import { karin, render, segment, logger } from "node-karin";
8
+ var ffmpegDemo = karin.command(/^#?ffmpeg渲染$/, async (e) => {
9
+ try {
10
+ const spec = path.join(dir.pluginDir, "resources", "template", "ffmpeg.ffrender.json");
11
+ const bg = path.join(dir.pluginDir, "resources", "image", "\u542F\u7A0B\u5BA3\u53D1.png");
12
+ const img = await render.render({
13
+ name: "ffmpegrender",
14
+ file: spec,
15
+ type: "png",
16
+ data: { file: bg }
17
+ }, "ffmpeg");
18
+ await e.reply(segment.image(`base64://${img}`));
19
+ return true;
20
+ } catch (error) {
21
+ logger.error(error);
22
+ await e.reply(String(error?.message || error));
23
+ return true;
24
+ }
25
+ }, {
26
+ name: "ffmpeg\u6E32\u67D3demo",
27
+ priority: 9999,
28
+ log: true,
29
+ permission: "all"
30
+ });
31
+ export {
32
+ ffmpegDemo
33
+ };
@@ -0,0 +1,391 @@
1
+ import {
2
+ config
3
+ } from "../chunk-2536PGJ3.js";
4
+ import "../chunk-NF24Q4FD.js";
5
+
6
+ // src/apps/ffmpegRenderer.ts
7
+ import { getRenderList as getRenderList2, logger as logger2, render } from "node-karin";
8
+
9
+ // src/ffmpeg/renderer.ts
10
+ import fs from "fs/promises";
11
+ import fsSync from "fs";
12
+ import path from "path";
13
+ import crypto from "crypto";
14
+ import os from "os";
15
+ import { spawn } from "child_process";
16
+ import { fileURLToPath } from "url";
17
+ import { config as karinConfig, getRenderList, karinPathTemp, logger, renderTpl } from "node-karin";
18
+ var RENDER_ID = "ffmpeg";
19
+ var isHttpUrl = (input) => /^https?:\/\//i.test(input);
20
+ var isFileUrl = (input) => /^file:\/\//i.test(input);
21
+ var toFsPath = (input) => {
22
+ if (!isFileUrl(input)) return input;
23
+ const stripped = input.replace(/^file:\/\//i, "");
24
+ if (/^\/[A-Za-z]:[\\/]/.test(stripped)) return stripped.slice(1);
25
+ if (/^[A-Za-z]:[\\/]/.test(stripped)) return stripped;
26
+ try {
27
+ return fileURLToPath(input);
28
+ } catch {
29
+ return stripped;
30
+ }
31
+ };
32
+ var isLikelyImage = (input) => {
33
+ const clean = input.split("?")[0]?.split("#")[0] ?? input;
34
+ const ext = path.extname(clean).toLowerCase();
35
+ return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"].includes(ext);
36
+ };
37
+ var parseHexColor = (color) => {
38
+ const c = color.trim();
39
+ if (!c.startsWith("#")) return null;
40
+ const hex = c.slice(1);
41
+ if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(hex)) return null;
42
+ const rgb = hex.slice(0, 6).toLowerCase();
43
+ if (hex.length === 8) {
44
+ const a = Number.parseInt(hex.slice(6, 8), 16) / 255;
45
+ return { rgb, alpha: Number.isFinite(a) ? a : 1 };
46
+ }
47
+ return { rgb };
48
+ };
49
+ var toFfmpegColor = (color) => {
50
+ if (!color) return "black@0.0";
51
+ const c = color.trim();
52
+ if (c === "transparent") return "black@0.0";
53
+ const parsed = parseHexColor(c);
54
+ if (!parsed) {
55
+ if (!/^[a-zA-Z0-9#@._-]+$/.test(c)) throw new TypeError(`ffmpegrender: unsafe color value: ${c}`);
56
+ return c;
57
+ }
58
+ if (typeof parsed.alpha === "number") return `0x${parsed.rgb}@${parsed.alpha}`;
59
+ return `0x${parsed.rgb}`;
60
+ };
61
+ var escapeFilterValue = (value) => {
62
+ const normalized = value.replace(/\\/g, "/");
63
+ const escaped = normalized.replace(/'/g, "\\'").replace(/:/g, "\\:");
64
+ return `'${escaped}'`;
65
+ };
66
+ var resolveFfmpegBin = () => {
67
+ const cfg = config();
68
+ const fromPlugin = (cfg.ffmpegPath || "").trim();
69
+ const fromKarin = (karinConfig.ffmpegPath?.() || "").trim();
70
+ return fromPlugin || fromKarin || "ffmpeg";
71
+ };
72
+ var findDefaultFontFile = () => {
73
+ const cfg = config();
74
+ const configured = (cfg.ffmpegFontFile || "").trim();
75
+ if (configured && fsSync.existsSync(configured)) return configured;
76
+ const candidates = process.platform === "win32" ? [
77
+ "C:/Windows/Fonts/msyh.ttc",
78
+ "C:/Windows/Fonts/msyh.ttf",
79
+ "C:/Windows/Fonts/simhei.ttf",
80
+ "C:/Windows/Fonts/arial.ttf"
81
+ ] : process.platform === "darwin" ? [
82
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
83
+ "/System/Library/Fonts/Supplemental/Helvetica.ttf"
84
+ ] : [
85
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
86
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
87
+ "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
88
+ ];
89
+ return candidates.find((p) => fsSync.existsSync(p));
90
+ };
91
+ var resolveTempBaseDir = () => {
92
+ const base = (karinPathTemp || "").trim() || os.tmpdir();
93
+ return path.join(base, "ffmpegrender");
94
+ };
95
+ var spawnFfmpeg = async (args) => {
96
+ const cfg = config();
97
+ const ffmpegBin = resolveFfmpegBin();
98
+ const logCmd = Boolean(cfg.ffmpegLogCommand);
99
+ const timeoutMs = Number.isFinite(cfg.ffmpegTimeoutMs) ? cfg.ffmpegTimeoutMs : 3e4;
100
+ if (logCmd) logger.mark(`[ffmpeg] ${ffmpegBin} ${args.join(" ")}`);
101
+ return await new Promise((resolve, reject) => {
102
+ const child = spawn(ffmpegBin, args, {
103
+ windowsHide: true,
104
+ stdio: ["ignore", "pipe", "pipe"]
105
+ });
106
+ const stdout = [];
107
+ const stderr = [];
108
+ const timer = setTimeout(() => {
109
+ child.kill("SIGKILL");
110
+ reject(new Error(`[ffmpeg] timeout after ${timeoutMs}ms`));
111
+ }, timeoutMs);
112
+ child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
113
+ child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
114
+ child.on("error", (err) => {
115
+ clearTimeout(timer);
116
+ if (err?.code === "ENOENT") {
117
+ reject(new Error(`ffmpegrender: ffmpeg not found (${ffmpegBin}); set \`ffmpegPath\` in plugin config or install ffmpeg in PATH`));
118
+ return;
119
+ }
120
+ reject(err);
121
+ });
122
+ child.on("close", (code) => {
123
+ clearTimeout(timer);
124
+ if (code === 0) return resolve(Buffer.concat(stdout));
125
+ const errText = Buffer.concat(stderr).toString("utf8").trim();
126
+ reject(new Error(`[ffmpeg] exit ${code}: ${errText || "unknown error"}`));
127
+ });
128
+ });
129
+ };
130
+ var assertSpec = (spec) => {
131
+ if (!spec || typeof spec !== "object") throw new TypeError("ffmpegrender: spec must be an object");
132
+ const s = spec;
133
+ if (!Number.isFinite(s.width) || !Number.isFinite(s.height)) {
134
+ throw new TypeError("ffmpegrender: spec.width/spec.height must be numbers");
135
+ }
136
+ if (s.width <= 0 || s.height <= 0) {
137
+ throw new TypeError("ffmpegrender: spec.width/spec.height must be > 0");
138
+ }
139
+ if (s.layers && !Array.isArray(s.layers)) throw new TypeError("ffmpegrender: spec.layers must be an array");
140
+ };
141
+ var buildImageChain = (layer, transparentColor) => {
142
+ const w = Math.round(layer.width);
143
+ const h = Math.round(layer.height);
144
+ const fit = layer.fit || "cover";
145
+ const filters = ["format=rgba"];
146
+ if (fit === "contain") {
147
+ filters.push(`scale=w=${w}:h=${h}:force_original_aspect_ratio=decrease`);
148
+ filters.push(`pad=w=${w}:h=${h}:x=(ow-iw)/2:y=(oh-ih)/2:color=${transparentColor}`);
149
+ } else if (fit === "cover") {
150
+ filters.push(`scale=w=${w}:h=${h}:force_original_aspect_ratio=increase`);
151
+ filters.push(`crop=w=${w}:h=${h}`);
152
+ } else {
153
+ filters.push(`scale=w=${w}:h=${h}`);
154
+ }
155
+ if (typeof layer.opacity === "number" && layer.opacity >= 0 && layer.opacity < 1) {
156
+ filters.push(`colorchannelmixer=aa=${layer.opacity}`);
157
+ }
158
+ return filters.join(",");
159
+ };
160
+ var buildDrawtext = async (layer, tempDir) => {
161
+ const fontFile = layer.fontFile || findDefaultFontFile();
162
+ if (!fontFile) {
163
+ throw new Error("ffmpegrender: missing font file (set `ffmpegFontFile` in plugin config or layer.fontFile)");
164
+ }
165
+ const id = crypto.randomBytes(8).toString("hex");
166
+ const textFile = path.join(tempDir, `text-${id}.txt`);
167
+ await fs.writeFile(textFile, layer.text, "utf8");
168
+ const opts = [
169
+ `fontfile=${escapeFilterValue(fontFile)}`,
170
+ `textfile=${escapeFilterValue(textFile)}`,
171
+ "reload=0",
172
+ `x=${Math.round(layer.x)}`,
173
+ `y=${Math.round(layer.y)}`,
174
+ `fontsize=${Math.round(layer.fontSize)}`,
175
+ `fontcolor=${toFfmpegColor(layer.color || "#ffffff")}`
176
+ ];
177
+ const boxColor = layer.box?.color ? toFfmpegColor(layer.box.color) : void 0;
178
+ const border = layer.box?.border;
179
+ if (boxColor) {
180
+ opts.push("box=1");
181
+ opts.push(`boxcolor=${boxColor}`);
182
+ if (typeof border === "number" && border > 0) opts.push(`boxborderw=${Math.round(border)}`);
183
+ }
184
+ return { filter: `drawtext=${opts.join(":")}`, cleanupFile: textFile };
185
+ };
186
+ var normalizeBackgroundImage = (bg, width, height) => {
187
+ if (!bg?.src) return null;
188
+ return {
189
+ type: "image",
190
+ src: bg.src,
191
+ x: 0,
192
+ y: 0,
193
+ width,
194
+ height,
195
+ fit: bg.fit || "cover"
196
+ };
197
+ };
198
+ var readSpec = async (filePath) => {
199
+ const raw = await fs.readFile(filePath, "utf8");
200
+ const spec = JSON.parse(raw);
201
+ assertSpec(spec);
202
+ return spec;
203
+ };
204
+ var tryReadSpec = async (filePath) => {
205
+ try {
206
+ return await readSpec(filePath);
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+ var renderSpecWithFfmpeg = async (spec, specDir, outType, outQuality) => {
212
+ const transparentColor = "black@0.0";
213
+ const bgColor = toFfmpegColor(spec.background?.color);
214
+ const imageLayers = [];
215
+ const bgImage = normalizeBackgroundImage(spec.background, spec.width, spec.height);
216
+ if (bgImage) imageLayers.push(bgImage);
217
+ for (const layer of spec.layers || []) {
218
+ if (layer.type === "image") imageLayers.push(layer);
219
+ }
220
+ const textLayers = (spec.layers || []).filter((l) => l.type === "text");
221
+ const inputFiles = imageLayers.map((l) => resolveAsset(l.src, specDir));
222
+ const tmpBase = resolveTempBaseDir();
223
+ await fs.mkdir(tmpBase, { recursive: true });
224
+ const tempDir = await fs.mkdtemp(path.join(tmpBase, "run-"));
225
+ const textCleanup = [];
226
+ try {
227
+ const filterParts = [];
228
+ filterParts.push("[0:v]format=rgba[base0]");
229
+ let current = "base0";
230
+ let step = 0;
231
+ for (let i = 0; i < imageLayers.length; i++) {
232
+ const inputIndex = i + 1;
233
+ const layer = imageLayers[i];
234
+ const imgLabel = `img${inputIndex}`;
235
+ filterParts.push(`[${inputIndex}:v]${buildImageChain(layer, transparentColor)}[${imgLabel}]`);
236
+ const next = `base${++step}`;
237
+ filterParts.push(`[${current}][${imgLabel}]overlay=${Math.round(layer.x)}:${Math.round(layer.y)}:format=auto[${next}]`);
238
+ current = next;
239
+ }
240
+ for (const layer of textLayers) {
241
+ const { filter, cleanupFile } = await buildDrawtext(layer, tempDir);
242
+ textCleanup.push(cleanupFile);
243
+ const next = `base${++step}`;
244
+ filterParts.push(`[${current}]${filter}[${next}]`);
245
+ current = next;
246
+ }
247
+ const filterComplex = filterParts.join(";");
248
+ const args = [
249
+ "-hide_banner",
250
+ "-loglevel",
251
+ "error",
252
+ "-f",
253
+ "lavfi",
254
+ "-i",
255
+ `color=c=${bgColor}:s=${Math.round(spec.width)}x${Math.round(spec.height)}:d=1`
256
+ ];
257
+ for (const input of inputFiles) args.push("-i", input);
258
+ args.push(
259
+ "-filter_complex",
260
+ filterComplex,
261
+ "-map",
262
+ `[${current}]`,
263
+ "-frames:v",
264
+ "1",
265
+ "-an",
266
+ "-sn"
267
+ );
268
+ if (outType === "png") {
269
+ args.push("-f", "image2pipe", "-vcodec", "png", "-pix_fmt", "rgba", "-");
270
+ } else if (outType === "webp") {
271
+ args.push("-f", "image2pipe", "-vcodec", "libwebp", "-pix_fmt", "yuva420p", "-");
272
+ } else {
273
+ const q = typeof outQuality === "number" ? clamp(outQuality, 1, 100) : 90;
274
+ const qscale = String(Math.round(31 - q / 100 * 29));
275
+ args.push("-q:v", qscale, "-f", "image2pipe", "-vcodec", "mjpeg", "-");
276
+ }
277
+ const output = await spawnFfmpeg(args);
278
+ return output;
279
+ } finally {
280
+ await Promise.allSettled(textCleanup.map((f) => fs.unlink(f)));
281
+ await fs.rm(tempDir, { recursive: true, force: true });
282
+ }
283
+ };
284
+ var clamp = (n, min, max) => Math.min(max, Math.max(min, n));
285
+ var resolveAsset = (asset, baseDir) => {
286
+ const src = asset.trim();
287
+ if (isHttpUrl(src)) return src;
288
+ const fsPath = toFsPath(src);
289
+ if (path.isAbsolute(fsPath)) return fsPath;
290
+ return path.resolve(baseDir, fsPath);
291
+ };
292
+ var delegateToAnotherRenderer = async (options) => {
293
+ const list = getRenderList();
294
+ const other = list.find((r) => r.id !== RENDER_ID);
295
+ if (!other) return null;
296
+ return await other.render(options);
297
+ };
298
+ var inferOutputType = (options) => {
299
+ const t = options.type;
300
+ if (t === "jpeg" || t === "webp" || t === "png") return t;
301
+ return "png";
302
+ };
303
+ var ffmpegRender = async (options) => {
304
+ const file = options?.file;
305
+ if (typeof file !== "string") throw new TypeError("ffmpegrender: options.file must be a string");
306
+ if (!isHttpUrl(file) && options.data) {
307
+ options = renderTpl(options);
308
+ }
309
+ const normalizedFile = options.file;
310
+ const outType = inferOutputType(options);
311
+ const outQuality = options.quality;
312
+ if (isHttpUrl(normalizedFile) && !isLikelyImage(normalizedFile)) {
313
+ const delegated2 = await delegateToAnotherRenderer(options);
314
+ if (delegated2) return delegated2;
315
+ throw new Error("ffmpegrender: http input is not an image; please install a puppeteer/snapka renderer for HTML/URL rendering");
316
+ }
317
+ if (isHttpUrl(normalizedFile) && isLikelyImage(normalizedFile)) {
318
+ const buffer = await renderImageWithFfmpeg(normalizedFile, outType, outQuality);
319
+ const b64 = buffer.toString("base64");
320
+ const multiPage = options.multiPage;
321
+ if (multiPage) return [b64];
322
+ return b64;
323
+ }
324
+ const fsPath = path.resolve(toFsPath(normalizedFile));
325
+ if (isLikelyImage(fsPath)) {
326
+ const buffer = await renderImageWithFfmpeg(fsPath, outType, outQuality);
327
+ const b64 = buffer.toString("base64");
328
+ const multiPage = options.multiPage;
329
+ if (multiPage) return [b64];
330
+ return b64;
331
+ }
332
+ const ext = path.extname(fsPath).toLowerCase();
333
+ const maybeSpec = ext === ".json" || ext === ".ffrender";
334
+ if (maybeSpec) {
335
+ const spec = await tryReadSpec(fsPath);
336
+ if (spec) {
337
+ const specDir = path.dirname(fsPath);
338
+ const buffer = await renderSpecWithFfmpeg(spec, specDir, outType, outQuality);
339
+ const b64 = buffer.toString("base64");
340
+ const multiPage = options.multiPage;
341
+ if (multiPage) return [b64];
342
+ return b64;
343
+ }
344
+ }
345
+ const delegated = await delegateToAnotherRenderer(options);
346
+ if (delegated) return delegated;
347
+ throw new Error(`ffmpegrender: unsupported input: ${normalizedFile}`);
348
+ };
349
+ var renderImageWithFfmpeg = async (input, outType, outQuality) => {
350
+ const args = [
351
+ "-hide_banner",
352
+ "-loglevel",
353
+ "error",
354
+ "-i",
355
+ input,
356
+ "-frames:v",
357
+ "1",
358
+ "-an",
359
+ "-sn"
360
+ ];
361
+ if (outType === "png") {
362
+ args.push("-f", "image2pipe", "-vcodec", "png", "-pix_fmt", "rgba", "-");
363
+ } else if (outType === "webp") {
364
+ args.push("-f", "image2pipe", "-vcodec", "libwebp", "-pix_fmt", "yuva420p", "-");
365
+ } else {
366
+ const q = typeof outQuality === "number" ? clamp(outQuality, 1, 100) : 90;
367
+ const qscale = String(Math.round(31 - q / 100 * 29));
368
+ args.push("-q:v", qscale, "-f", "image2pipe", "-vcodec", "mjpeg", "-");
369
+ }
370
+ return await spawnFfmpeg(args);
371
+ };
372
+
373
+ // src/apps/ffmpegRenderer.ts
374
+ var RENDER_ID2 = "ffmpeg";
375
+ try {
376
+ const exists = getRenderList2().some((r) => r.id === RENDER_ID2);
377
+ if (!exists) {
378
+ render.app({
379
+ id: RENDER_ID2,
380
+ type: "image",
381
+ render: ffmpegRender
382
+ });
383
+ logger2.mark(`[ffmpegrender] renderer registered: ${RENDER_ID2}`);
384
+ }
385
+ } catch (err) {
386
+ logger2.error("[ffmpegrender] renderer register failed", err);
387
+ }
388
+ var ffmpegRenderer = RENDER_ID2;
389
+ export {
390
+ ffmpegRenderer
391
+ };
@@ -0,0 +1,24 @@
1
+ // src/apps/handler.ts
2
+ import { karin, handler } from "node-karin";
3
+ var test = karin.handler("test.image", (args, reject) => {
4
+ return "Handler\u5904\u7406\u5B8C\u6210";
5
+ });
6
+ var testHandler = karin.command(/^#?测试handler$/, async (e) => {
7
+ const msg = "\u6D4B\u8BD5handler";
8
+ const res = await handler.call("test.image", { e, msg });
9
+ await e.reply(res);
10
+ return true;
11
+ }, {
12
+ /** 插件优先级 */
13
+ priority: 9999,
14
+ /** 插件触发是否打印触发日志 */
15
+ log: true,
16
+ /** 插件名称 */
17
+ name: "\u6D4B\u8BD5handler",
18
+ /** 谁可以触发这个插件 'all' | 'master' | 'admin' | 'group.owner' | 'group.admin' */
19
+ permission: "all"
20
+ });
21
+ export {
22
+ test,
23
+ testHandler
24
+ };
@@ -0,0 +1,89 @@
1
+ import {
2
+ config
3
+ } from "../chunk-2536PGJ3.js";
4
+ import {
5
+ dir
6
+ } from "../chunk-NF24Q4FD.js";
7
+
8
+ // src/apps/render.ts
9
+ import { karin, render, segment, logger } from "node-karin";
10
+ var image = karin.command(/^#?测试渲染$/, async (e) => {
11
+ try {
12
+ const html = dir.defResourcesDir + "/template/test.html";
13
+ const image2 = dir.defResourcesDir + "/image/\u542F\u7A0B\u5BA3\u53D1.png";
14
+ const img = await render.render({
15
+ name: "render",
16
+ encoding: "base64",
17
+ file: html,
18
+ data: {
19
+ file: image2,
20
+ pluResPath: process.cwd()
21
+ },
22
+ pageGotoParams: {
23
+ waitUntil: "networkidle2"
24
+ }
25
+ });
26
+ await e.reply(segment.image(`base64://${img}`));
27
+ return true;
28
+ } catch (error) {
29
+ logger.error(error);
30
+ await e.reply(JSON.stringify(error));
31
+ return true;
32
+ }
33
+ }, {
34
+ /** 插件优先级 */
35
+ priority: 9999,
36
+ /** 插件触发是否打印触发日志 */
37
+ log: true,
38
+ /** 插件名称 */
39
+ name: "\u6D4B\u8BD5\u6E32\u67D3",
40
+ /** 谁可以触发这个插件 'all' | 'master' | 'admin' | 'group.owner' | 'group.admin' */
41
+ permission: "all"
42
+ });
43
+ var renderUrl = karin.command(/^#?渲染/, async (e) => {
44
+ const file = e.msg.replace(/^#?渲染/, "").trim();
45
+ try {
46
+ const img = await render.render({
47
+ name: "render",
48
+ encoding: "base64",
49
+ file: file || "https://whitechi73.github.io/OpenShamrock/",
50
+ type: "png",
51
+ pageGotoParams: {
52
+ waitUntil: "networkidle2"
53
+ },
54
+ setViewport: {
55
+ width: 1920,
56
+ height: 1080,
57
+ deviceScaleFactor: 1
58
+ }
59
+ });
60
+ await e.reply(segment.image(`base64://${img}`));
61
+ return true;
62
+ } catch (error) {
63
+ logger.error(error);
64
+ await e.reply(error.message);
65
+ return true;
66
+ }
67
+ }, {
68
+ /** 插件优先级 */
69
+ priority: 9999,
70
+ /** 插件触发是否打印触发日志 */
71
+ log: true,
72
+ /** 插件名称 */
73
+ name: "\u6E32\u67D3demo",
74
+ /** 谁可以触发这个插件 'all' | 'master' | 'admin' | 'group.owner' | 'group.admin' */
75
+ permission: "master"
76
+ });
77
+ var screenshot = karin.command("^#\u6D4B\u8BD5\u622A\u56FE$", async (e) => {
78
+ const { screenshotUrl } = config();
79
+ const img = await karin.render(screenshotUrl);
80
+ await e.reply(segment.image(`base64://${img}`));
81
+ return true;
82
+ }, {
83
+ name: "\u6D4B\u8BD5\u622A\u56FE"
84
+ });
85
+ export {
86
+ image,
87
+ renderUrl,
88
+ screenshot
89
+ };
@@ -0,0 +1,128 @@
1
+ import {
2
+ config
3
+ } from "../chunk-2536PGJ3.js";
4
+ import "../chunk-NF24Q4FD.js";
5
+
6
+ // src/apps/sendMsg.ts
7
+ import { karin, segment, common } from "node-karin";
8
+ var yiyanApi = karin.command(/^#一言$/, async (e) => {
9
+ const { yiyanApi: yiyanApi2 } = config();
10
+ await e.reply(segment.image(yiyanApi2));
11
+ }, {
12
+ name: "\u4E00\u8A00api"
13
+ });
14
+ var sendMsg = karin.command(/^#测试主动消息$/, async (e) => {
15
+ const selfId = e.selfId;
16
+ const contact = e.contact;
17
+ const messages = [
18
+ "\u2728 \u54C7\uFF01\u8FD9\u662F\u4E00\u6761\u8D85\u53EF\u7231\u7684\u4E3B\u52A8\u6D88\u606F\uFF0C10\u79D2\u540E\u5C31\u4F1A\u795E\u79D8\u6D88\u5931\u54E6~ \u2728",
19
+ "\u{1F338} \u53EE\u549A\uFF01\u6211\u4E3B\u52A8\u627E\u4F60\u804A\u5929\u5566\uFF0C\u53EF\u60DC10\u79D2\u540E\u6211\u5C31\u8981\u6E9C\u8D70\u5566~ \u{1F338}",
20
+ "\u{1F380} \u4F60\u597D\u5440\uFF01\u8FD9\u662F\u4E00\u6761\u4F1A\u81EA\u5DF1\u8DD1\u6389\u7684\u6D88\u606F\uFF0C\u5012\u8BA1\u65F610\u79D2\u5F00\u59CB\uFF01\u{1F380}",
21
+ "\u{1F36D} \u7A81\u7136\u51FA\u73B0\u7684\u751C\u751C\u6D88\u606F\uFF01\u522B\u6025\u7740\u56DE\u590D\uFF0C10\u79D2\u540E\u6211\u5C31\u6D88\u5931\u5566~ \u{1F36D}"
22
+ ];
23
+ const randomMsg = messages[Math.floor(Math.random() * messages.length)];
24
+ const text = `
25
+ ${randomMsg}`;
26
+ const { messageId } = await karin.sendMsg(selfId, contact, text, { recallMsg: 10 });
27
+ console.log(`\u2705 \u6D88\u606F\u5DF2\u9001\u8FBE\uFF0C\u6D88\u606FID\uFF1A${messageId}`);
28
+ return true;
29
+ }, {
30
+ /** 插件优先级 */
31
+ priority: 9999,
32
+ /** 插件触发是否打印触发日志 */
33
+ log: true,
34
+ /** 插件名称 */
35
+ name: "\u53EF\u7231\u4E3B\u52A8\u6D88\u606Fdemo",
36
+ /** 谁可以触发这个插件 'all' | 'master' | 'admin' | 'group.owner' | 'group.admin' */
37
+ permission: "all"
38
+ });
39
+ var forwardMessage = karin.command(/^#测试转发$/, async (e) => {
40
+ const message = [
41
+ segment.text("\u{1F31F} \u8FD9\u662F\u8F6C\u53D1\u7684\u7B2C\u4E00\u6761\u6D88\u606F \u{1F31F}"),
42
+ segment.text("\u2728 \u8FD9\u662F\u8F6C\u53D1\u7684\u7B2C\u4E8C\u6761\u6D88\u606F \u2728"),
43
+ segment.text("\u{1F496} \u8FD9\u662F\u8F6C\u53D1\u7684\u6700\u540E\u4E00\u6761\u6D88\u606F \u{1F496}")
44
+ ];
45
+ const content = common.makeForward(message, e.selfId, e.bot.account.name);
46
+ await e.bot.sendForwardMsg(e.contact, content);
47
+ return true;
48
+ }, {
49
+ /** 插件优先级 */
50
+ priority: 9999,
51
+ /** 插件触发是否打印触发日志 */
52
+ log: true,
53
+ /** 插件名称 */
54
+ name: "\u53EF\u7231\u8F6C\u53D1demo",
55
+ /** 谁可以触发这个插件 'all' | 'master' | 'admin' | 'group.owner' | 'group.admin' */
56
+ permission: "all"
57
+ });
58
+ var randomEmoji = karin.command(/^#随机表情$/, async (e) => {
59
+ const emojiUrls = [
60
+ "https://i.imgur.com/XaUdU2C.gif",
61
+ "https://i.imgur.com/wF2RkHB.gif",
62
+ "https://i.imgur.com/7voHalT.jpg",
63
+ "https://i.imgur.com/QMlZUdZ.gif",
64
+ "https://i.imgur.com/o2JQjAn.gif"
65
+ ];
66
+ const randomUrl = emojiUrls[Math.floor(Math.random() * emojiUrls.length)];
67
+ const message = [
68
+ segment.text("\u{1F389} \u968F\u673A\u8868\u60C5\u5305\u6765\u5566\uFF01"),
69
+ segment.image(randomUrl)
70
+ ];
71
+ await e.reply(message);
72
+ return true;
73
+ }, {
74
+ priority: 9999,
75
+ log: true,
76
+ name: "\u968F\u673A\u8868\u60C5\u5305demo",
77
+ permission: "all"
78
+ });
79
+ var dailyQuote = karin.command(/^#每日一言$/, async (e) => {
80
+ const quotes = [
81
+ "\u4ECA\u5929\u4E5F\u662F\u5145\u6EE1\u5E0C\u671B\u7684\u4E00\u5929\uFF01\u52A0\u6CB9\uFF01\u2728",
82
+ "\u4EBA\u751F\u5C31\u50CF\u4E00\u76D2\u5DE7\u514B\u529B\uFF0C\u4F60\u6C38\u8FDC\u4E0D\u77E5\u9053\u4E0B\u4E00\u5757\u662F\u4EC0\u4E48\u5473\u9053\u3002\u{1F36B}",
83
+ "\u5FAE\u7B11\u662F\u4E16\u754C\u4E0A\u6700\u7F8E\u4E3D\u7684\u8BED\u8A00\u3002\u{1F60A}",
84
+ "\u6210\u529F\u4E0D\u662F\u7EC8\u70B9\uFF0C\u5931\u8D25\u4E5F\u4E0D\u662F\u7EC8\u7ED3\uFF0C\u91CD\u8981\u7684\u662F\u7EE7\u7EED\u524D\u8FDB\u7684\u52C7\u6C14\u3002\u{1F680}",
85
+ "\u505A\u81EA\u5DF1\u7684\u592A\u9633\uFF0C\u4E0D\u5FC5\u4EF0\u671B\u522B\u4EBA\uFF01\u{1F4AB}",
86
+ "\u751F\u6D3B\u5C31\u50CF\u9A91\u81EA\u884C\u8F66\uFF0C\u60F3\u4FDD\u6301\u5E73\u8861\u5C31\u5F97\u524D\u8FDB\u3002\u{1F6B2}",
87
+ "\u6700\u91CD\u8981\u7684\u662F\u7231\u81EA\u5DF1\uFF0C\u56E0\u4E3A\u8FD9\u6837\u4F60\u7684\u7075\u9B42\u624D\u4F1A\u53D1\u5149\u3002\u{1F496}"
88
+ ];
89
+ const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
90
+ const message = [
91
+ segment.text(`\u{1F4AD} \u6BCF\u65E5\u4E00\u8A00\uFF1A${randomQuote}`)
92
+ ];
93
+ await e.reply(message);
94
+ return true;
95
+ }, {
96
+ priority: 9999,
97
+ log: true,
98
+ name: "\u6BCF\u65E5\u4E00\u8A00demo",
99
+ permission: "all"
100
+ });
101
+ var weatherForecast = karin.command(/^#今日天气$/, async (e) => {
102
+ const weathers = [
103
+ "\u2600\uFE0F \u6674\u5929\uFF0C\u6E29\u5EA625\xB0C\uFF0C\u9002\u5408\u51FA\u95E8\u73A9\u800D~",
104
+ "\u{1F327}\uFE0F \u5C0F\u96E8\uFF0C\u6E29\u5EA618\xB0C\uFF0C\u8BB0\u5F97\u5E26\u4F1E\u54E6\uFF01",
105
+ "\u26C5 \u591A\u4E91\uFF0C\u6E29\u5EA622\xB0C\uFF0C\u9634\u6674\u4E0D\u5B9A\u7684\u4E00\u5929~",
106
+ "\u{1F32B}\uFE0F \u96FE\u5929\uFF0C\u6E29\u5EA615\xB0C\uFF0C\u80FD\u89C1\u5EA6\u8F83\u4F4E\uFF0C\u51FA\u884C\u6CE8\u610F\u5B89\u5168\uFF01",
107
+ "\u{1F324}\uFE0F \u5C40\u90E8\u6674\u6717\uFF0C\u6E29\u5EA620\xB0C\uFF0C\u5076\u6709\u5C0F\u4E91\u6735\u906E\u6321\u9633\u5149~"
108
+ ];
109
+ const randomWeather = weathers[Math.floor(Math.random() * weathers.length)];
110
+ const message = [
111
+ segment.text(`\u{1F308} \u4ECA\u65E5\u5929\u6C14\u9884\u62A5\uFF1A${randomWeather}`)
112
+ ];
113
+ await e.reply(message);
114
+ return true;
115
+ }, {
116
+ priority: 9999,
117
+ log: true,
118
+ name: "\u5929\u6C14\u9884\u62A5demo",
119
+ permission: "all"
120
+ });
121
+ export {
122
+ dailyQuote,
123
+ forwardMessage,
124
+ randomEmoji,
125
+ sendMsg,
126
+ weatherForecast,
127
+ yiyanApi
128
+ };
@@ -0,0 +1,19 @@
1
+ // src/apps/task.ts
2
+ import { karin, logger } from "node-karin";
3
+ var Task = karin.task("\u6BCF\u5206\u949F\u7684\u8DA3\u5473\u95EE\u5019", "0 */1 * * * *", () => {
4
+ const funnyMessages = [
5
+ "\u4F60\u597D\u5440\uFF0C\u53C8\u89C1\u9762\u4E86\uFF01",
6
+ "\u53C8\u8FC7\u53BB\u4E00\u5206\u949F\uFF0C\u6211\u8FD8\u5728\u575A\u6301\u5DE5\u4F5C~",
7
+ "\u563F\uFF0C\u770B\u770B\u8C01\u53C8\u6309\u65F6\u62A5\u5230\u4E86\uFF01",
8
+ "\u65F6\u95F4\u8FC7\u5F97\u771F\u5FEB\uFF0C\u6211\u90FD\u6570\u4E0D\u6E05\u8FD9\u662F\u7B2C\u51E0\u6B21\u89C1\u4F60\u4E86",
9
+ "\u53EE\u549A\uFF01\u60A8\u7684\u6BCF\u5206\u949F\u63D0\u9192\u670D\u52A1\u5DF2\u9001\u8FBE",
10
+ "\u5DE5\u4F5Cing...\u522B\u62C5\u5FC3\uFF0C\u6211\u4E0D\u4F1A\u5077\u61D2\u7684",
11
+ "\u6EF4\u7B54\u6EF4\u7B54\uFF0C\u65F6\u949F\u5728\u8D70\uFF0C\u6211\u5728\u966A\u4F60",
12
+ "\u6211\u662F\u6700\u52E4\u52B3\u7684\u4EFB\u52A1\uFF0C\u4ECE\u4E0D\u7F3A\u52E4\uFF01"
13
+ ];
14
+ const randomIndex = Math.floor(Math.random() * funnyMessages.length);
15
+ logger.info(`\u{1F389} ${funnyMessages[randomIndex]} \u{1F389}`);
16
+ }, { log: false });
17
+ export {
18
+ Task
19
+ };
@@ -0,0 +1,61 @@
1
+ import {
2
+ dir
3
+ } from "./chunk-NF24Q4FD.js";
4
+
5
+ // src/utils/config.ts
6
+ import fs from "fs";
7
+ import path from "path";
8
+ import {
9
+ watch,
10
+ logger,
11
+ filesByExt,
12
+ copyConfigSync,
13
+ requireFileSync
14
+ } from "node-karin";
15
+ copyConfigSync(dir.defConfigDir, dir.ConfigDir, [".json"]);
16
+ var copyDirMissingSync = (srcDir, destDir) => {
17
+ if (!fs.existsSync(srcDir)) return;
18
+ fs.mkdirSync(destDir, { recursive: true });
19
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
20
+ for (const entry of entries) {
21
+ const srcPath = path.join(srcDir, entry.name);
22
+ const destPath = path.join(destDir, entry.name);
23
+ if (entry.isDirectory()) {
24
+ copyDirMissingSync(srcPath, destPath);
25
+ continue;
26
+ }
27
+ if (!entry.isFile()) continue;
28
+ if (!fs.existsSync(destPath)) {
29
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
30
+ fs.copyFileSync(srcPath, destPath);
31
+ }
32
+ }
33
+ };
34
+ try {
35
+ copyDirMissingSync(path.join(dir.pluginDir, "resources"), dir.defResourcesDir);
36
+ } catch (err) {
37
+ logger.error("[ffmpegrender] copy resources failed", err);
38
+ }
39
+ var config = () => {
40
+ const cfg = requireFileSync(`${dir.ConfigDir}/config.json`);
41
+ const def = requireFileSync(`${dir.defConfigDir}/config.json`);
42
+ return { ...def, ...cfg };
43
+ };
44
+ setTimeout(() => {
45
+ const list = filesByExt(dir.ConfigDir, ".json", "abs");
46
+ list.forEach((file) => watch(file, (old, now) => {
47
+ logger.info([
48
+ "QAQ: \u68C0\u6D4B\u5230\u914D\u7F6E\u6587\u4EF6\u66F4\u65B0",
49
+ `\u8FD9\u662F\u65E7\u6570\u636E: ${old}`,
50
+ `\u8FD9\u662F\u65B0\u6570\u636E: ${now}`
51
+ ].join("\n"));
52
+ }));
53
+ }, 2e3);
54
+
55
+ // src/utils/common.ts
56
+ import lodash from "node-karin/lodash";
57
+ import moment from "node-karin/moment";
58
+
59
+ export {
60
+ config
61
+ };
@@ -0,0 +1,43 @@
1
+ // src/dir.ts
2
+ import path from "path";
3
+ import { URL, fileURLToPath } from "url";
4
+ import { karinPathBase, requireFileSync } from "node-karin";
5
+ var pluginDir = fileURLToPath(new URL("../", import.meta.url));
6
+ var pluginName = path.basename(pluginDir);
7
+ var pkg = requireFileSync(path.join(pluginDir, "package.json"));
8
+ var dir = {
9
+ /** 根目录绝对路径 */
10
+ pluginDir,
11
+ /** 插件目录名称 */
12
+ pluginName,
13
+ /** package.json */
14
+ pkg,
15
+ /** 插件版本 package.json 的 version */
16
+ get version() {
17
+ return pkg.version;
18
+ },
19
+ /** 插件名称 package.json 的 name */
20
+ get name() {
21
+ return pkg.name;
22
+ },
23
+ /** 插件默认配置目录 */
24
+ get defConfigDir() {
25
+ return path.join(pluginDir, "config");
26
+ },
27
+ /** 在`@karinjs`中的绝对路径 */
28
+ get karinPath() {
29
+ return path.join(karinPathBase, pluginName);
30
+ },
31
+ /** 插件配置目录 `@karinjs/karin-plugin-xxx/config` */
32
+ get ConfigDir() {
33
+ return path.join(this.karinPath, "config");
34
+ },
35
+ /** 插件资源目录 `@karinjs/karin-plugin-xxx/resources` */
36
+ get defResourcesDir() {
37
+ return path.join(this.karinPath, "resources");
38
+ }
39
+ };
40
+
41
+ export {
42
+ dir
43
+ };
package/lib/dir.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ dir
3
+ } from "./chunk-NF24Q4FD.js";
4
+ export {
5
+ dir
6
+ };
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import {
2
+ dir
3
+ } from "./chunk-NF24Q4FD.js";
4
+
5
+ // src/index.ts
6
+ import { logger } from "node-karin";
7
+ logger.info(`${logger.violet(`[\u63D2\u4EF6:${dir.version}]`)} ${logger.green(dir.name)} \u521D\u59CB\u5316\u5B8C\u6210~`);
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "karin-plugin-ffmpegrender",
3
+ "version": "0.0.1",
4
+ "author": "429",
5
+ "type": "module",
6
+ "description": "karin plugin template",
7
+ "homepage": "https://github.com/KarinJS/karin",
8
+ "bugs": {
9
+ "url": "https://github.com/KarinJS/karin/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/KarinJS/karin.git"
14
+ },
15
+ "scripts": {
16
+ "app": "node lib/app.js",
17
+ "build": "tsup",
18
+ "dev": "cross-env EBV_FILE=\"development.env\" node --import tsx src/app.ts",
19
+ "pub": "npm publish --access public"
20
+ },
21
+ "main": "lib/index.js",
22
+ "devDependencies": {
23
+ "@types/express": "^5.0.1",
24
+ "@types/lodash": "^4.17.16",
25
+ "@types/node": "^20.17.8",
26
+ "cross-env": "^7.0.3",
27
+ "eslint": "^9.7.0",
28
+ "neostandard": "^0.11.9",
29
+ "node-karin": "*",
30
+ "tsup": "^8.5.0",
31
+ "tsx": "^4.19.2",
32
+ "typescript": "^5.5.3"
33
+ },
34
+ "karin": {
35
+ "main": "src/index.ts",
36
+ "apps": [
37
+ "lib/apps"
38
+ ],
39
+ "ts-apps": [
40
+ "src/apps"
41
+ ],
42
+ "static": [
43
+ "resources"
44
+ ],
45
+ "files": [
46
+ "config",
47
+ "data",
48
+ "resources"
49
+ ]
50
+ },
51
+ "files": [
52
+ "/lib/**/*.js",
53
+ "/lib/**/*.d.ts",
54
+ "/config/*.json",
55
+ "resources",
56
+ "!lib/app.js"
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org"
61
+ },
62
+ "dependencies": {
63
+ "@karinjs/plugin-puppeteer": "^1.1.2"
64
+ }
65
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "width": 960,
3
+ "height": 540,
4
+ "background": {
5
+ "color": "#ffffffff"
6
+ },
7
+ "layers": [
8
+ {
9
+ "type": "image",
10
+ "src": "{{@(file || '').replace(/\\/g, '/')}}",
11
+ "x": 40,
12
+ "y": 60,
13
+ "width": 320,
14
+ "height": 420,
15
+ "fit": "cover"
16
+ },
17
+ {
18
+ "type": "text",
19
+ "text": "{{@(title || '').replace(/\\/g, '\\\\').replace(/\"/g, '\\\"').replace(/\r?\n/g, '\\n')}}",
20
+ "x": 390,
21
+ "y": 90,
22
+ "fontSize": 44,
23
+ "color": "#111111"
24
+ },
25
+ {
26
+ "type": "text",
27
+ "text": "{{@(subtitle || '').replace(/\\/g, '\\\\').replace(/\"/g, '\\\"').replace(/\r?\n/g, '\\n')}}",
28
+ "x": 390,
29
+ "y": 160,
30
+ "fontSize": 26,
31
+ "color": "#555555"
32
+ },
33
+ {
34
+ "type": "text",
35
+ "text": "{{@(tag || '').replace(/\\/g, '\\\\').replace(/\"/g, '\\\"').replace(/\r?\n/g, '\\n')}}",
36
+ "x": 390,
37
+ "y": 220,
38
+ "fontSize": 22,
39
+ "color": "#2b6cb0",
40
+ "box": {
41
+ "color": "#2b6cb033",
42
+ "border": 10
43
+ }
44
+ },
45
+ {
46
+ "type": "text",
47
+ "text": "{{@(desc || '').replace(/\\/g, '\\\\').replace(/\"/g, '\\\"').replace(/\r?\n/g, '\\n')}}",
48
+ "x": 390,
49
+ "y": 280,
50
+ "fontSize": 24,
51
+ "color": "#222222"
52
+ },
53
+ {
54
+ "type": "text",
55
+ "text": "{{@(footer || '').replace(/\\/g, '\\\\').replace(/\"/g, '\\\"').replace(/\r?\n/g, '\\n')}}",
56
+ "x": 40,
57
+ "y": 505,
58
+ "fontSize": 18,
59
+ "color": "#666666"
60
+ }
61
+ ]
62
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "width": 1067,
3
+ "height": 600,
4
+ "background": {
5
+ "color": "#00000000"
6
+ },
7
+ "layers": [
8
+ {
9
+ "type": "image",
10
+ "src": "{{@(file || '').replace(/\\/g, '/')}}",
11
+ "x": 0,
12
+ "y": 0,
13
+ "width": 1067,
14
+ "height": 600,
15
+ "fit": "cover"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <style>
6
+ body {
7
+ width: 1067px;
8
+ height: 600px;
9
+ margin: 0;
10
+ padding: 0;
11
+ background-size: cover;
12
+ }
13
+ </style>
14
+ </head>
15
+
16
+ <body>
17
+ <!-- 本地图片 -->
18
+ <img src="{{@file}}" alt="启程宣发">
19
+ </body>
20
+
21
+ </html>