@karinjs/plugin-shotium 0.0.1 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KarinJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
1
  # @karinjs/plugin-shotium
2
2
 
3
- Placeholder release. Real content is published via CI (OIDC).
3
+ karin [shotium](https://github.com/sj817/shotium) 截图渲染插件。
4
+
5
+ shotium 是一个被裁剪过的 chromium:只留下 Blink 布局、Skia CPU 光栅化、字体与图片解码,以及 chromium 的网络栈,
6
+ V8、content 框架、GPU 进程、DevTools 协议全部拿掉了。
7
+ 它以 Node-API 的形式跑在 node 进程里,没有浏览器进程,也没有 CDP 往返。
8
+
9
+ 对 karin 来说,这意味着渲染模板图这件事不再需要装一个 100+ MB 的浏览器。
10
+ 代价是页面里的 JavaScript 不会执行——模板必须是服务端渲染好的静态 HTML。
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ pnpm add @karinjs/plugin-shotium -w
16
+ ```
17
+
18
+ 装好重启 karin 即可,插件会自己把渲染器注册进 karin 的渲染器列表。
19
+
20
+ ## 它做了什么
21
+
22
+ karin 的渲染器就是一个函数:收下 `Options`,还回 base64。
23
+ 这个插件把 karin/puppeteer 那一套参数翻译成 shotium 的参数,再把结果翻译回去。
24
+
25
+ | karin | shotium | 说明 |
26
+ | --- | --- | --- |
27
+ | `file` | `file` | 本地路径按配置转成 `file://` 或桥接 http 地址 |
28
+ | `data` | — | 交给 karin 的 `renderTpl` 先跑 art-template |
29
+ | `selector` | `selector` | 引擎内部用 `Document::querySelector` 解析,不注入脚本 |
30
+ | `fullPage` | `fullPage` | 与 `selector` 互斥,`fullPage` 优先 |
31
+ | `setViewport.width/height` | `viewport` | |
32
+ | `setViewport.deviceScaleFactor` | `scale` | |
33
+ | `type` / `quality` | `type` / `quality` | png 不带 `quality` |
34
+ | `omitBackground` | `omitBackground` | jpeg 没有 alpha,会被忽略 |
35
+ | `clip` | `clip` | `clip.scale` 无对应项 |
36
+ | `pageGotoParams.waitUntil` | `pageGotoParams.waitUntil` | `networkidle0/2` 统一归到 `networkidle` |
37
+ | `pageGotoParams.timeout` | `pageGotoParams.timeout` | `0`(不超时) 回退到引擎默认值 |
38
+ | `headers` | `headers` | 只对同源子资源生效,见下 |
39
+ | `multiPage` | — | 插件侧对同一张图做无损切割 |
40
+ | `waitForSelector` 等 | — | 引擎没有 JS 运行时,忽略并提示一次 |
41
+
42
+ ### 分片渲染
43
+
44
+ `multiPage` 在 puppeteer 那边是「改视窗高度重截几次」,
45
+ 这里换成了「截一次,再把 PNG 按行切开」:不重新布局,也就不会出现分片之间样式对不上的情况。
46
+ 切割直接在 zlib 层面做,不依赖任何图像库。
47
+
48
+ 因为切割器只认 PNG,`multiPage` 配上 jpeg/webp 时会自动切到 png,并在日志里提示一次。
49
+
50
+ 切割要把整张图重压一遍,这一步在大图上并不便宜。1440x2541 那张图上,
51
+ 同一份像素 deflate level 9 要 1.3s、level 6 要 0.48s、level 3 只要 0.19s,
52
+ 而体积差别只有个位数百分比,所以默认用 level 3,可以通过 `sliceCompression` 调。
53
+
54
+ ### 本地文件怎么交给引擎
55
+
56
+ `localAccess` 有三档:
57
+
58
+ - `file`(默认路径):`file://` 直接打开,同时打开引擎的 `allowFileAccess`,
59
+ 模板里 `file://` 形式的图片、字体照常能读到。
60
+ - `http`:借用 **karin 自己的 express 服务**,把本地文件挂在一个带随机 token 的路径下转成 `http://` 再截图。
61
+ 插件不另开端口。
62
+ http 文档下 chromium 会拒绝加载 `file://` 子资源,所以这条路径上 HTML/CSS 里的 `file://` 引用会被一并改写成桥接地址。
63
+ - `auto`(默认):平时走 `file`,只有调用方传了 `headers` 时才切到 `http`——
64
+ 引擎只对同源子资源发送自定义头,`file://` 文档没有同源可言,headers 会被整个丢掉。
65
+
66
+ 桥接路径里带一段进程级随机 token,并且只放行渲染会用到的后缀,
67
+ 避免 karin 监听在 `0.0.0.0` 时把工作目录直接暴露出去。
68
+
69
+ ## 配置
70
+
71
+ 首次运行会在 `@karinjs/@karinjs-plugin-shotium/config/config.json` 生成默认配置,也可以在 WebUI 里改。
72
+
73
+ | 字段 | 默认值 | 说明 |
74
+ | --- | --- | --- |
75
+ | `mode` | `inprocess` | `inprocess` 进程内 / `daemon` 常驻守护进程 |
76
+ | `localAccess` | `auto` | 见上 |
77
+ | `type` | `png` | 调用方没指定时的截图格式 |
78
+ | `quality` | `90` | jpeg / webp 的压缩质量 |
79
+ | `viewport` | `800x600` | 默认视窗 |
80
+ | `scale` | `1` | 默认设备像素比 |
81
+ | `timeout` | `30000` | 默认导航超时 |
82
+ | `autoMultiPageHeight` | `4000` | `multiPage: true` 时每片的高度 |
83
+ | `sliceCompression` | `3` | 分片重新编码的 deflate 级别 0-9 |
84
+ | `cacheDir` | `''` | HTTP 磁盘缓存目录,留空用引擎默认,填 `off` 关闭 |
85
+ | `cacheMaxBytes` | `256MB` | 缓存上限 |
86
+ | `userAgent` | `''` | 留空使用引擎内置 |
87
+ | `idleTimeoutMs` | `300000` | 守护进程空闲退出时间 |
88
+ | `logStats` | `true` | 日志里带上引擎耗时统计 |
89
+
90
+ 改完配置会热重建引擎,不需要重启 karin。
91
+
92
+ ## 性能与实测
93
+
94
+ 性能结论以 Shotium 主仓库的[六平台基准报告](https://sj817.github.io/shotium/)为准。
95
+ README 不再混用不同机器、版本和页面的绝对耗时,也不跨平台排名。
96
+
97
+ ### 标准基准
98
+
99
+ 下面摘录 Shotium 0.3.3 的 `linux-x64` 合格结果。三者在同一原生 runner、同一静态页面、
100
+ PNG、1280×720、scale 1、`waitUntil: load` 条件下测试;表内为 p50,越低越好。
101
+
102
+ | 场景 | Shotium | Puppeteer Shell | Puppeteer Chrome |
103
+ | --- | ---: | ---: | ---: |
104
+ | 冷启动 | 53 ms | 282 ms(5.32×) | 471 ms(8.89×) |
105
+ | 启动稳定后的首张 | 21.028 ms | 104.061 ms(4.95×) | 114.146 ms(5.43×) |
106
+ | 预热截图 | 24.905 ms | 131.601 ms(5.28×) | 157.438 ms(6.32×) |
107
+ | 启动—截图—关闭循环 | 53.989 ms | 289.344 ms(5.36×) | 565.139 ms(10.47×) |
108
+
109
+ 数据来源:[Shotium 0.3.3 基准报告](https://github.com/sj817/shotium/blob/main/benchmark-results/v0.3.3/20260830T000525Z-gh33274826755-a1/report.zh-CN.md)。
110
+ 该次六平台聚合结果标记为“不完整”,所以上表只引用其中状态为“通过”、允许排名的
111
+ `linux-x64` 同平台数据,不推测缺失结果。
112
+
113
+ ### KKK 帮助卡片兼容性实测
114
+
115
+ 2026-08-30 使用 `karin-plugin-kkk` 2.42.2、`@karinjs/plugin-shotium` 0.1.0 和
116
+ Shotium 0.3.3,在 Windows x64 对实际 SSR 帮助卡片进行了明暗主题冒烟测试:
117
+
118
+ | 主题 | 尺寸 | PNG 大小 | 插件总耗时 | 引擎耗时 |
119
+ | --- | ---: | ---: | ---: | ---: |
120
+ | 亮色 | 1440×2673 | 5.58 MB | 800 ms | 781.2 ms |
121
+ | 暗色 | 1440×2673 | 6.01 MB | 746 ms | 732.5 ms |
122
+
123
+ 两张卡片的布局、中文、背景和主题均正确。此处只有各一次样本,而且隔离测试没有启动
124
+ KKK 的 3780 字体服务,20 个 HarmonyOS 字体请求回退到系统字体,因此它只用于兼容性证明,
125
+ 不参与上面的性能排名。
126
+
127
+ 这类大卡片会同时承担数 MB CSS 解析和大尺寸 PNG 编码成本;Shotium 主要节省的是冷启动、
128
+ 常驻内存与浏览器进程/CDP 往返开销。页面越小、调用越频繁,固定开销差异越明显。
129
+
130
+ 需要更快的话,按收益排序:
131
+
132
+ 1. 输出换成 `jpeg` 或 `webp`;代价是 jpeg 没有 alpha 通道,`omitBackground` 会失效。
133
+ 2. 模板侧把 CSS purge 掉,别把整份 tailwind 内联进每张图。
134
+ 3. 图别做那么大——同一份内容宽度减半,像素少四分之三。
135
+
136
+ ## 已知限制
137
+
138
+ - **页面里的 JavaScript 不会执行。** 模板必须是 SSR 好的静态 HTML。
139
+ 用 Vue/React 组件路径当模板的 `file_type`(`vue3`、`vueString`、`react`)会直接报错。
140
+ - `data:` 开头的地址不能当作**主文档**(子资源里的 data URI 正常)。
141
+ 插件会把 `file_type: 'htmlString'` 的字符串先落盘再截。
142
+ - 没有 `waitForSelector` / `waitForFunction` / `waitForRequest` / `waitForResponse`。
143
+ - 抗锯齿是确定性的灰阶抗锯齿,和 puppeteer 的输出会有极细微的差别。
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { logger, karin } from 'node-karin';
4
+ import { basePath } from 'node-karin/root';
5
+
6
+ // package.json
7
+ var package_default = {
8
+ name: "@karinjs/plugin-shotium",
9
+ version: "0.2.1",
10
+ description: "karin \u7684 shotium \u622A\u56FE\u3001\u6E32\u67D3\u63D2\u4EF6",
11
+ keywords: [
12
+ "karin",
13
+ "node-karin",
14
+ "karin-plugin",
15
+ "shotium",
16
+ "screenshot",
17
+ "render"
18
+ ],
19
+ license: "MIT",
20
+ author: "sj817",
21
+ type: "module",
22
+ main: "dist/index.js",
23
+ types: "dist/index.d.ts",
24
+ files: [
25
+ "dist"
26
+ ],
27
+ repository: {
28
+ type: "git",
29
+ url: "git+https://github.com/KarinJS/plugin-shotium.git"
30
+ },
31
+ scripts: {
32
+ build: "tsc && tsup",
33
+ dev: "tsx src/app.ts",
34
+ lint: "eslint src/",
35
+ pub: "pnpm publish --access public",
36
+ rm: `node -e "require('fs').rmSync('dist', { recursive: true, force: true })"`,
37
+ sync: 'curl -X PUT "https://registry-direct.npmmirror.com/-/package/@karinjs/plugin-shotium/syncs"',
38
+ test: "vitest run",
39
+ "test:watch": "vitest"
40
+ },
41
+ dependencies: {
42
+ "@shotkit/shotium": "^0.3.3"
43
+ },
44
+ devDependencies: {
45
+ "@types/express": "5.0.6",
46
+ "@types/node": "^22.19.15",
47
+ eslint: "^9.7.0",
48
+ neostandard: "^0.12.1",
49
+ "node-karin": "^1.17.0",
50
+ tsup: "^8.5.1",
51
+ tsx: "^4.19.3",
52
+ typescript: "^5.8.2",
53
+ vitest: "^4.1.8"
54
+ },
55
+ karin: {
56
+ main: "src/index.ts",
57
+ apps: [],
58
+ web: "dist/web.config.js",
59
+ "ts-web": "src/web.config.ts",
60
+ files: [
61
+ "config"
62
+ ]
63
+ },
64
+ publishConfig: {
65
+ access: "public",
66
+ registry: "https://registry.npmjs.org"
67
+ }
68
+ };
69
+ var HMR_KEY = "karin-plugin-shotium-hmr";
70
+ var defaultConfig = {
71
+ mode: "inprocess",
72
+ localAccess: "auto",
73
+ type: "png",
74
+ quality: 90,
75
+ viewport: {
76
+ width: 800,
77
+ height: 600
78
+ },
79
+ scale: 1,
80
+ timeout: 3e4,
81
+ autoMultiPageHeight: 4e3,
82
+ sliceCompression: 3,
83
+ cacheDir: "",
84
+ cacheMaxBytes: 256 * 1024 * 1024,
85
+ userAgent: "",
86
+ idleTimeoutMs: 3e5,
87
+ logStats: true
88
+ };
89
+ var pluginName = package_default.name.replace(/\//g, "-");
90
+ var pluginVersion = package_default.version;
91
+ var configPath = path.resolve(basePath, pluginName, "config", "config.json");
92
+ var init = () => {
93
+ if (!fs.existsSync(configPath)) {
94
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
95
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
96
+ logger.info(`[${pluginName}] \u9996\u6B21\u8FD0\u884C\uFF0C\u5DF2\u521B\u5EFA\u9ED8\u8BA4\u914D\u7F6E\u6587\u4EF6: ${configPath}`);
97
+ }
98
+ };
99
+ var getConfig = () => {
100
+ try {
101
+ const data = JSON.parse(fs.readFileSync(configPath, "utf-8"));
102
+ return {
103
+ ...defaultConfig,
104
+ ...data,
105
+ viewport: { ...defaultConfig.viewport, ...data?.viewport ?? {} }
106
+ };
107
+ } catch (error) {
108
+ logger.error(`[${pluginName}] \u914D\u7F6E\u8BFB\u53D6\u5931\u8D25\uFF0C\u5DF2\u56DE\u9000\u5230\u9ED8\u8BA4\u914D\u7F6E: ${error instanceof Error ? error.message : String(error)}`);
109
+ return { ...defaultConfig };
110
+ }
111
+ };
112
+ var saveConfig = (config) => {
113
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
114
+ karin.emit(HMR_KEY, config);
115
+ };
116
+ init();
117
+
118
+ export { HMR_KEY, configPath, defaultConfig, getConfig, package_default, pluginName, pluginVersion, saveConfig };
@@ -0,0 +1,224 @@
1
+ import { ScreenshotOptions } from '@shotkit/shotium';
2
+ import { Snapka } from 'node-karin';
3
+
4
+ var name = "@karinjs/plugin-shotium";
5
+ var version = "0.2.1";
6
+ var description = "karin 的 shotium 截图、渲染插件";
7
+ var keywords = [
8
+ "karin",
9
+ "node-karin",
10
+ "karin-plugin",
11
+ "shotium",
12
+ "screenshot",
13
+ "render"
14
+ ];
15
+ var license = "MIT";
16
+ var author = "sj817";
17
+ var type = "module";
18
+ var main = "dist/index.js";
19
+ var types = "dist/index.d.ts";
20
+ var files = [
21
+ "dist"
22
+ ];
23
+ var repository = {
24
+ type: "git",
25
+ url: "git+https://github.com/KarinJS/plugin-shotium.git"
26
+ };
27
+ var scripts = {
28
+ build: "tsc && tsup",
29
+ dev: "tsx src/app.ts",
30
+ lint: "eslint src/",
31
+ pub: "pnpm publish --access public",
32
+ rm: "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
33
+ sync: "curl -X PUT \"https://registry-direct.npmmirror.com/-/package/@karinjs/plugin-shotium/syncs\"",
34
+ test: "vitest run",
35
+ "test:watch": "vitest"
36
+ };
37
+ var dependencies = {
38
+ "@shotkit/shotium": "^0.3.3"
39
+ };
40
+ var devDependencies = {
41
+ "@types/express": "5.0.6",
42
+ "@types/node": "^22.19.15",
43
+ eslint: "^9.7.0",
44
+ neostandard: "^0.12.1",
45
+ "node-karin": "^1.17.0",
46
+ tsup: "^8.5.1",
47
+ tsx: "^4.19.3",
48
+ typescript: "^5.8.2",
49
+ vitest: "^4.1.8"
50
+ };
51
+ var karin = {
52
+ main: "src/index.ts",
53
+ apps: [
54
+ ],
55
+ web: "dist/web.config.js",
56
+ "ts-web": "src/web.config.ts",
57
+ files: [
58
+ "config"
59
+ ]
60
+ };
61
+ var publishConfig = {
62
+ access: "public",
63
+ registry: "https://registry.npmjs.org"
64
+ };
65
+ var _package = {
66
+ name: name,
67
+ version: version,
68
+ description: description,
69
+ keywords: keywords,
70
+ license: license,
71
+ author: author,
72
+ type: type,
73
+ main: main,
74
+ types: types,
75
+ files: files,
76
+ repository: repository,
77
+ scripts: scripts,
78
+ dependencies: dependencies,
79
+ devDependencies: devDependencies,
80
+ karin: karin,
81
+ publishConfig: publishConfig
82
+ };
83
+
84
+ /**
85
+ * 热更新key
86
+ */
87
+ declare const HMR_KEY = "karin-plugin-shotium-hmr";
88
+ /**
89
+ * 本地文件的加载方式
90
+ * - `file`: 直接以 `file://` 交给引擎,配合 `allowFileAccess` 读取同目录下的子资源
91
+ * - `http`: 借用 karin 自身的 express 服务把本地文件转成 `http://` 再交给引擎
92
+ * - `auto`: 默认走 `file`,当调用方传了 `headers` 时自动切到 `http`
93
+ * (引擎只对同源子资源发送自定义头,`file://` 文档没有同源可言)
94
+ */
95
+ type LocalAccess = 'auto' | 'file' | 'http';
96
+ /**
97
+ * 插件配置
98
+ */
99
+ interface ShotiumConfig {
100
+ /** 引擎运行方式:进程内 / 常驻守护进程 */
101
+ mode: 'inprocess' | 'daemon';
102
+ /** 本地文件加载方式 */
103
+ localAccess: LocalAccess;
104
+ /** 默认截图类型 */
105
+ type: 'png' | 'jpeg' | 'webp';
106
+ /** 默认压缩质量 1-100,仅 jpeg/webp 生效 */
107
+ quality: number;
108
+ /** 默认视窗 */
109
+ viewport: {
110
+ width: number;
111
+ height: number;
112
+ };
113
+ /** 默认设备像素比 0.01-8 */
114
+ scale: number;
115
+ /** 默认导航超时(ms) */
116
+ timeout: number;
117
+ /** `multiPage: true` 时单张分片的最大高度(css px) */
118
+ autoMultiPageHeight: number;
119
+ /**
120
+ * 分片重新编码时的 deflate 级别 0-9
121
+ *
122
+ * 分片要把引擎给的整张 PNG 拆开重压一遍,这一步在大图上并不便宜:
123
+ * 同一份像素 level 9 要 1.3s、level 6 要 0.5s、level 3 只要 0.19s,
124
+ * 而体积差别只有个位数百分比。发到聊天里的图不值得为这点体积多等半秒。
125
+ */
126
+ sliceCompression: number;
127
+ /** HTTP 磁盘缓存目录,留空使用引擎默认目录,填 `off` 关闭缓存 */
128
+ cacheDir: string;
129
+ /** HTTP 磁盘缓存上限(字节) */
130
+ cacheMaxBytes: number;
131
+ /** 自定义 UA,留空使用引擎内置 */
132
+ userAgent: string;
133
+ /** daemon 模式下的空闲退出时间(ms),0 表示不退出 */
134
+ idleTimeoutMs: number;
135
+ /** 打印每次截图的耗时统计 */
136
+ logStats: boolean;
137
+ }
138
+ /**
139
+ * 默认配置
140
+ */
141
+ declare const defaultConfig: ShotiumConfig;
142
+ /** 插件名称 */
143
+ declare const pluginName: string;
144
+ /** 插件版本 */
145
+ declare const pluginVersion: string;
146
+ /** 配置文件路径 */
147
+ declare const configPath: string;
148
+ /**
149
+ * 获取配置
150
+ * @returns 合并默认值之后的配置
151
+ */
152
+ declare const getConfig: () => ShotiumConfig;
153
+ /**
154
+ * 保存配置
155
+ * @param config 配置
156
+ */
157
+ declare const saveConfig: (config: ShotiumConfig) => void;
158
+
159
+ /**
160
+ * karin 里能传、但 shotium 做不到的选项
161
+ *
162
+ * shotium 砍掉了 V8,页面里没有脚本会跑,
163
+ * 这些「等某个东西发生」的开关自然也就没有意义,遇到时提示一次即可。
164
+ */
165
+ declare const UNSUPPORTED_KEYS: readonly ["waitForSelector", "waitForFunction", "waitForRequest", "waitForResponse"];
166
+ /**
167
+ * 把 karin 的 `waitUntil` 归一成 shotium 的两档
168
+ *
169
+ * @param waitUntil karin/puppeteer 的取值,可能是数组
170
+ * @returns shotium 的取值
171
+ */
172
+ declare const toWaitUntil: (waitUntil?: string | string[]) => "load" | "networkidle" | undefined;
173
+ /**
174
+ * 把 karin 的截图参数翻译成 shotium 的截图参数
175
+ *
176
+ * 只做纯粹的字段映射,`file` 由调用方在外面决定用 `file://` 还是走 http 桥接。
177
+ *
178
+ * @param options karin 截图参数
179
+ * @param config 插件配置
180
+ * @returns shotium 截图参数(不含 `file`)
181
+ */
182
+ declare const toScreenshotOptions: (options: Snapka, config: ShotiumConfig) => Omit<ScreenshotOptions, "file">;
183
+ /**
184
+ * 取出调用方传了、但引擎不支持的选项名
185
+ * @param options karin 截图参数
186
+ * @returns 选项名列表
187
+ */
188
+ declare const pickUnsupported: (options: Snapka) => string[];
189
+ /**
190
+ * 计算每一片的高度(css px)
191
+ * @param multiPage karin 的 multiPage 参数
192
+ * @param autoHeight `true` 时使用的默认高度
193
+ * @returns 分片高度,不分片时返回 0
194
+ */
195
+ declare const toSliceHeight: (multiPage: number | boolean | undefined, autoHeight: number) => number;
196
+
197
+ /** PNG 基本信息 */
198
+ interface PngInfo {
199
+ width: number;
200
+ height: number;
201
+ bitDepth: number;
202
+ colorType: number;
203
+ interlace: number;
204
+ }
205
+ /**
206
+ * 读取 PNG 的宽高等基本信息
207
+ * @param buf PNG 数据
208
+ * @returns 基本信息,不是合法 PNG 时返回 null
209
+ */
210
+ declare const readPngInfo: (buf: Buffer) => PngInfo | null;
211
+ /**
212
+ * 把一张 PNG 按高度切成若干张 PNG
213
+ *
214
+ * 与「改视窗高度重新截图」相比,这里是对同一次渲染结果做无损切割:
215
+ * 不会重新布局,也就不会出现分片之间样式对不上的问题。
216
+ *
217
+ * @param buf 原始 PNG
218
+ * @param sliceHeight 每片高度(设备像素)
219
+ * @param level deflate 级别 0-9,越低越快、体积越大
220
+ * @returns 切好的 PNG 列表;无法处理时返回 null,由调用方回退
221
+ */
222
+ declare const splitPng: (buf: Buffer, sliceHeight: number, level?: number) => Buffer[] | null;
223
+
224
+ export { HMR_KEY, type LocalAccess, type PngInfo, type ShotiumConfig, UNSUPPORTED_KEYS, configPath, defaultConfig, getConfig, pickUnsupported, _package as pkg, pluginName, pluginVersion, readPngInfo, saveConfig, splitPng, toScreenshotOptions, toSliceHeight, toWaitUntil };
package/dist/index.js ADDED
@@ -0,0 +1,498 @@
1
+ import { getConfig, HMR_KEY, pluginName, pluginVersion } from './chunk-76RKNDUT.js';
2
+ export { HMR_KEY, configPath, defaultConfig, getConfig, package_default as pkg, pluginName, pluginVersion, saveConfig } from './chunk-76RKNDUT.js';
3
+ import fs2 from 'node:fs';
4
+ import path2 from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { karin, logger, registerRender, renderTpl, app } from 'node-karin';
8
+ import { karinPathHtml } from 'node-karin/root';
9
+ import { start, status, stop, screenshot, daemon } from '@shotkit/shotium';
10
+ import zlib from 'node:zlib';
11
+
12
+ var toStartOptions = (config) => {
13
+ const options = {
14
+ cacheMaxBytes: config.cacheMaxBytes
15
+ };
16
+ const cacheDir = (config.cacheDir ?? "").trim();
17
+ if (cacheDir === "off") {
18
+ options.cacheDir = null;
19
+ } else if (cacheDir) {
20
+ options.cacheDir = cacheDir;
21
+ }
22
+ if (config.userAgent) options.userAgent = config.userAgent;
23
+ return options;
24
+ };
25
+ var createInprocessEngine = (config) => {
26
+ const result = start(toStartOptions(config));
27
+ logger.info(
28
+ `[${pluginName}] \u8FDB\u7A0B\u5185\u5F15\u64CE\u5DF2\u542F\u52A8: cache=${result.cacheActive ? result.cacheDir : "\u5DF2\u5173\u95ED"}`
29
+ );
30
+ return {
31
+ screenshot: (options) => screenshot(options),
32
+ close: async () => {
33
+ if (status().running) await stop();
34
+ }
35
+ };
36
+ };
37
+ var createDaemonEngine = (config) => {
38
+ let client = null;
39
+ let pending = null;
40
+ const connect = async () => {
41
+ if (client) return client;
42
+ if (!pending) {
43
+ pending = daemon.connect({
44
+ ...toStartOptions(config),
45
+ idleTimeoutMs: config.idleTimeoutMs
46
+ }).then((connected) => {
47
+ client = connected;
48
+ connected.once("close", () => {
49
+ client = null;
50
+ logger.warn(`[${pluginName}] \u5B88\u62A4\u8FDB\u7A0B\u8FDE\u63A5\u5DF2\u65AD\u5F00\uFF0C\u4E0B\u6B21\u622A\u56FE\u65F6\u4F1A\u91CD\u8FDE`);
51
+ });
52
+ logger.info(`[${pluginName}] \u5B88\u62A4\u8FDB\u7A0B\u5F15\u64CE\u5DF2\u8FDE\u63A5`);
53
+ return connected;
54
+ }).finally(() => {
55
+ pending = null;
56
+ });
57
+ }
58
+ return pending;
59
+ };
60
+ return {
61
+ screenshot: async (options) => {
62
+ try {
63
+ return await (await connect()).screenshot(options);
64
+ } catch (error) {
65
+ client = null;
66
+ return await (await connect()).screenshot(options);
67
+ }
68
+ },
69
+ close: async () => {
70
+ client?.close();
71
+ client = null;
72
+ }
73
+ };
74
+ };
75
+ var createEngine = (config) => {
76
+ return config.mode === "daemon" ? createDaemonEngine(config) : createInprocessEngine(config);
77
+ };
78
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
79
+ var CHANNELS = {
80
+ 0: 1,
81
+ // 灰度
82
+ 2: 3,
83
+ // RGB
84
+ 4: 2,
85
+ // 灰度 + alpha
86
+ 6: 4
87
+ // RGBA
88
+ };
89
+ var CRC_TABLE = (() => {
90
+ const table = new Int32Array(256);
91
+ for (let n = 0; n < 256; n++) {
92
+ let c = n;
93
+ for (let k = 0; k < 8; k++) {
94
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
95
+ }
96
+ table[n] = c;
97
+ }
98
+ return table;
99
+ })();
100
+ var crc32 = (buf) => {
101
+ let c = -1;
102
+ for (let i = 0; i < buf.length; i++) {
103
+ c = CRC_TABLE[(c ^ buf[i]) & 255] ^ c >>> 8;
104
+ }
105
+ return (c ^ -1) >>> 0;
106
+ };
107
+ var readChunks = (buf) => {
108
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
109
+ const chunks = [];
110
+ let offset = 8;
111
+ while (offset + 8 <= buf.length) {
112
+ const length = buf.readUInt32BE(offset);
113
+ const type = buf.toString("ascii", offset + 4, offset + 8);
114
+ const start2 = offset + 8;
115
+ const end = start2 + length;
116
+ if (end + 4 > buf.length) return null;
117
+ chunks.push({ type, data: buf.subarray(start2, end) });
118
+ offset = end + 4;
119
+ if (type === "IEND") break;
120
+ }
121
+ return chunks;
122
+ };
123
+ var writeChunk = (type, data) => {
124
+ const length = Buffer.alloc(4);
125
+ length.writeUInt32BE(data.length, 0);
126
+ const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
127
+ const crc = Buffer.alloc(4);
128
+ crc.writeUInt32BE(crc32(typeAndData), 0);
129
+ return Buffer.concat([length, typeAndData, crc]);
130
+ };
131
+ var readPngInfo = (buf) => {
132
+ if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
133
+ if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
134
+ return {
135
+ width: buf.readUInt32BE(16),
136
+ height: buf.readUInt32BE(20),
137
+ bitDepth: buf[24],
138
+ colorType: buf[25],
139
+ interlace: buf[28]
140
+ };
141
+ };
142
+ var paeth = (a, b, c) => {
143
+ const p = a + b - c;
144
+ const pa = Math.abs(p - a);
145
+ const pb = Math.abs(p - b);
146
+ const pc = Math.abs(p - c);
147
+ if (pa <= pb && pa <= pc) return a;
148
+ if (pb <= pc) return b;
149
+ return c;
150
+ };
151
+ var unfilter = (filtered, width, height, bpp) => {
152
+ const stride = width * bpp;
153
+ const out = Buffer.alloc(stride * height);
154
+ for (let y = 0; y < height; y++) {
155
+ const filterType = filtered[y * (stride + 1)];
156
+ const src = y * (stride + 1) + 1;
157
+ const dst = y * stride;
158
+ const up = dst - stride;
159
+ for (let x = 0; x < stride; x++) {
160
+ const raw = filtered[src + x];
161
+ const a = x >= bpp ? out[dst + x - bpp] : 0;
162
+ const b = y > 0 ? out[up + x] : 0;
163
+ const c = x >= bpp && y > 0 ? out[up + x - bpp] : 0;
164
+ let value;
165
+ switch (filterType) {
166
+ case 0:
167
+ value = raw;
168
+ break;
169
+ case 1:
170
+ value = raw + a;
171
+ break;
172
+ case 2:
173
+ value = raw + b;
174
+ break;
175
+ case 3:
176
+ value = raw + (a + b >> 1);
177
+ break;
178
+ case 4:
179
+ value = raw + paeth(a, b, c);
180
+ break;
181
+ default:
182
+ value = raw;
183
+ }
184
+ out[dst + x] = value & 255;
185
+ }
186
+ }
187
+ return out;
188
+ };
189
+ var splitPng = (buf, sliceHeight, level = 3) => {
190
+ const info = readPngInfo(buf);
191
+ if (!info) return null;
192
+ if (info.interlace !== 0 || info.bitDepth !== 8) return null;
193
+ const channels = CHANNELS[info.colorType];
194
+ if (!channels) return null;
195
+ const height = Math.max(1, Math.floor(sliceHeight));
196
+ if (height >= info.height) return [buf];
197
+ const chunks = readChunks(buf);
198
+ if (!chunks) return null;
199
+ const idat = chunks.filter((item) => item.type === "IDAT").map((item) => item.data);
200
+ if (idat.length === 0) return null;
201
+ let raw;
202
+ try {
203
+ raw = zlib.inflateSync(Buffer.concat(idat));
204
+ } catch {
205
+ return null;
206
+ }
207
+ const stride = info.width * channels;
208
+ if (raw.length < (stride + 1) * info.height) return null;
209
+ const pixels = unfilter(raw, info.width, info.height, channels);
210
+ const extras = chunks.filter((item) => ["PLTE", "tRNS", "gAMA", "sRGB", "cHRM", "iCCP", "pHYs"].includes(item.type));
211
+ const list = [];
212
+ for (let top = 0; top < info.height; top += height) {
213
+ const sliceRows = Math.min(height, info.height - top);
214
+ const body = Buffer.alloc((stride + 1) * sliceRows);
215
+ for (let y = 0; y < sliceRows; y++) {
216
+ body[y * (stride + 1)] = 0;
217
+ pixels.copy(body, y * (stride + 1) + 1, (top + y) * stride, (top + y + 1) * stride);
218
+ }
219
+ const ihdr = Buffer.alloc(13);
220
+ ihdr.writeUInt32BE(info.width, 0);
221
+ ihdr.writeUInt32BE(sliceRows, 4);
222
+ ihdr[8] = info.bitDepth;
223
+ ihdr[9] = info.colorType;
224
+ ihdr[10] = 0;
225
+ ihdr[11] = 0;
226
+ ihdr[12] = 0;
227
+ list.push(Buffer.concat([
228
+ PNG_SIGNATURE,
229
+ writeChunk("IHDR", ihdr),
230
+ ...extras.map((item) => writeChunk(item.type, item.data)),
231
+ writeChunk("IDAT", zlib.deflateSync(body, { level })),
232
+ writeChunk("IEND", Buffer.alloc(0))
233
+ ]));
234
+ }
235
+ return list;
236
+ };
237
+ var ALLOWED_EXT = {
238
+ ".html": "text/html; charset=utf-8",
239
+ ".htm": "text/html; charset=utf-8",
240
+ ".css": "text/css; charset=utf-8",
241
+ ".js": "text/javascript; charset=utf-8",
242
+ ".mjs": "text/javascript; charset=utf-8",
243
+ ".json": "application/json; charset=utf-8",
244
+ ".txt": "text/plain; charset=utf-8",
245
+ ".svg": "image/svg+xml",
246
+ ".png": "image/png",
247
+ ".jpg": "image/jpeg",
248
+ ".jpeg": "image/jpeg",
249
+ ".gif": "image/gif",
250
+ ".webp": "image/webp",
251
+ ".avif": "image/avif",
252
+ ".bmp": "image/bmp",
253
+ ".ico": "image/x-icon",
254
+ ".woff": "font/woff",
255
+ ".woff2": "font/woff2",
256
+ ".ttf": "font/ttf",
257
+ ".otf": "font/otf",
258
+ ".eot": "application/vnd.ms-fontobject"
259
+ };
260
+ var REWRITE_EXT = /* @__PURE__ */ new Set([".html", ".htm", ".css", ".svg"]);
261
+ var token = crypto.randomBytes(16).toString("hex");
262
+ var mountPath = `/shotium/${token}/fs`;
263
+ var mounted = false;
264
+ var toUrlPath = (file) => {
265
+ const posix = path2.resolve(file).split(path2.sep).join("/").replace(/^\/+/, "");
266
+ return `${mountPath}/${posix.split("/").map(encodeURIComponent).join("/")}`;
267
+ };
268
+ var toFilePath = (urlPath) => {
269
+ const raw = decodeURIComponent(urlPath).replace(/^\/+/, "");
270
+ return /^[a-zA-Z]:/.test(raw) ? raw : `/${raw}`;
271
+ };
272
+ var rewriteFileUrls = (text) => {
273
+ return text.replace(/file:\/\/\/?[^"')\s>]+/g, (match) => {
274
+ try {
275
+ return toUrlPath(fileURLToPath(new URL(match)));
276
+ } catch {
277
+ return match;
278
+ }
279
+ });
280
+ };
281
+ var handler = (req, res) => {
282
+ const file = path2.normalize(toFilePath(req.path));
283
+ const ext = path2.extname(file).toLowerCase();
284
+ if (file.includes("..")) {
285
+ res.status(403).end("forbidden");
286
+ return;
287
+ }
288
+ const type = ALLOWED_EXT[ext];
289
+ if (!type) {
290
+ res.status(415).end("unsupported file type");
291
+ return;
292
+ }
293
+ if (!fs2.existsSync(file) || !fs2.statSync(file).isFile()) {
294
+ res.status(404).end("not found");
295
+ return;
296
+ }
297
+ res.setHeader("Content-Type", type);
298
+ if (REWRITE_EXT.has(ext)) {
299
+ res.end(rewriteFileUrls(fs2.readFileSync(file, "utf-8")));
300
+ return;
301
+ }
302
+ fs2.createReadStream(file).pipe(res);
303
+ };
304
+ var mountBridge = () => {
305
+ if (mounted) return;
306
+ app.use(mountPath, handler);
307
+ mounted = true;
308
+ logger.debug(`[${pluginName}] \u672C\u5730\u8D44\u6E90\u6865\u63A5\u5DF2\u6302\u8F7D: ${mountPath}`);
309
+ };
310
+ var isBridgeAvailable = () => {
311
+ return process.env.HTTP_ENABLE !== "false" && !!process.env.HTTP_PORT;
312
+ };
313
+ var toBridgeUrl = (file) => {
314
+ mountBridge();
315
+ const port = process.env.HTTP_PORT || "7777";
316
+ return `http://127.0.0.1:${port}${toUrlPath(file)}`;
317
+ };
318
+ var toFileUrl = (file) => pathToFileURL(path2.resolve(file)).href;
319
+
320
+ // src/convert.ts
321
+ var UNSUPPORTED_KEYS = [
322
+ "waitForSelector",
323
+ "waitForFunction",
324
+ "waitForRequest",
325
+ "waitForResponse"
326
+ ];
327
+ var toWaitUntil = (waitUntil) => {
328
+ if (!waitUntil) return void 0;
329
+ const list = Array.isArray(waitUntil) ? waitUntil : [waitUntil];
330
+ return list.some((item) => item.startsWith("networkidle")) ? "networkidle" : "load";
331
+ };
332
+ var toScreenshotOptions = (options, config) => {
333
+ const type = options.type ?? config.type;
334
+ const result = { type };
335
+ if (type !== "png") {
336
+ result.quality = options.quality ?? config.quality;
337
+ }
338
+ if (options.fullPage) {
339
+ result.fullPage = true;
340
+ } else if (options.selector) {
341
+ result.selector = options.selector;
342
+ }
343
+ if (options.omitBackground && type !== "jpeg") {
344
+ result.omitBackground = true;
345
+ }
346
+ if (options.clip && !result.fullPage && !result.selector) {
347
+ result.clip = {
348
+ x: options.clip.x,
349
+ y: options.clip.y,
350
+ width: options.clip.width,
351
+ height: options.clip.height
352
+ };
353
+ }
354
+ const width = options.setViewport?.width ?? config.viewport.width;
355
+ const height = options.setViewport?.height ?? config.viewport.height;
356
+ result.viewport = { width, height };
357
+ const scale = options.setViewport?.deviceScaleFactor ?? config.scale;
358
+ if (scale && scale !== 1) result.scale = scale;
359
+ const waitUntil = toWaitUntil(options.pageGotoParams?.waitUntil);
360
+ const timeout = options.pageGotoParams?.timeout;
361
+ if (waitUntil || typeof timeout === "number") {
362
+ result.pageGotoParams = {
363
+ ...waitUntil ? { waitUntil } : {},
364
+ /** puppeteer 用 0 表示不超时,shotium 没有这个约定,回退到默认值 */
365
+ ...typeof timeout === "number" && timeout > 0 ? { timeout } : {}
366
+ };
367
+ }
368
+ if (options.headers && Object.keys(options.headers).length > 0) {
369
+ result.headers = options.headers;
370
+ }
371
+ return result;
372
+ };
373
+ var pickUnsupported = (options) => {
374
+ return UNSUPPORTED_KEYS.filter((key) => {
375
+ const value = options[key];
376
+ return Array.isArray(value) ? value.length > 0 : !!value;
377
+ });
378
+ };
379
+ var toSliceHeight = (multiPage, autoHeight) => {
380
+ if (multiPage === true) return autoHeight;
381
+ if (typeof multiPage === "number" && multiPage > 0) return multiPage;
382
+ return 0;
383
+ };
384
+
385
+ // src/index.ts
386
+ var RENDER_ID = "@karinjs/plugin-shotium";
387
+ var stringHtmlDir = path2.join(karinPathHtml, "shotium");
388
+ var warned = /* @__PURE__ */ new Set();
389
+ var warnOnce = (key, message) => {
390
+ if (warned.has(key)) return;
391
+ warned.add(key);
392
+ logger.warn(`[${pluginName}] ${message}`);
393
+ };
394
+ var writeHtmlString = (html, name) => {
395
+ fs2.mkdirSync(stringHtmlDir, { recursive: true });
396
+ const hash = crypto.createHash("md5").update(html).digest("hex").slice(0, 8);
397
+ const file = path2.join(stringHtmlDir, `${name || "render"}-${hash}.html`);
398
+ if (!fs2.existsSync(file)) fs2.writeFileSync(file, html);
399
+ return file;
400
+ };
401
+ var resolveTarget = (file, config, hasHeaders) => {
402
+ if (/^https?:\/\//.test(file)) {
403
+ return { target: file, allowFileAccess: false };
404
+ }
405
+ const local = file.startsWith("file:") ? fileURLToPath(file) : path2.resolve(file);
406
+ const wantHttp = config.localAccess === "http" || config.localAccess === "auto" && hasHeaders;
407
+ if (wantHttp) {
408
+ if (isBridgeAvailable()) {
409
+ return { target: toBridgeUrl(local), allowFileAccess: false };
410
+ }
411
+ warnOnce("bridge", "karin \u7684 http \u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u672C\u5730\u6587\u4EF6\u5DF2\u56DE\u9000\u5230 file:// \u65B9\u5F0F");
412
+ }
413
+ return { target: toFileUrl(local), allowFileAccess: true };
414
+ };
415
+ var formatBytes = (bytes) => {
416
+ const units = ["B", "KB", "MB", "GB"];
417
+ if (!bytes || bytes < 0) return "0 B";
418
+ const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
419
+ const value = bytes / Math.pow(1024, i);
420
+ return `${i === 0 ? Math.round(value) : value.toFixed(2)} ${units[i]}`;
421
+ };
422
+ var main = async () => {
423
+ let config = getConfig();
424
+ let engine = createEngine(config);
425
+ karin.on(HMR_KEY, async () => {
426
+ logger.info(`[${pluginName}] \u68C0\u6D4B\u5230\u914D\u7F6E\u70ED\u66F4\u65B0\uFF0C\u6B63\u5728\u91CD\u5EFA\u5F15\u64CE...`);
427
+ const previous = engine;
428
+ config = getConfig();
429
+ engine = createEngine(config);
430
+ await previous.close().catch(() => {
431
+ });
432
+ logger.info(`[${pluginName}] \u5F15\u64CE\u91CD\u5EFA\u5B8C\u6210`);
433
+ });
434
+ registerRender(RENDER_ID, async (options) => {
435
+ const time = Date.now();
436
+ if (options.file_type && options.file_type !== "auto") {
437
+ if (options.file_type === "htmlString") {
438
+ options.file = writeHtmlString(options.file, options.file_name);
439
+ options.file_type = "auto";
440
+ } else {
441
+ throw new Error(
442
+ `[${pluginName}] shotium \u6CA1\u6709 JS \u8FD0\u884C\u65F6\uFF0C\u65E0\u6CD5\u6E32\u67D3 file_type: ${options.file_type}\uFF0C\u8BF7\u5148\u5728\u63D2\u4EF6\u4FA7\u5B8C\u6210 SSR \u518D\u628A html \u4EA4\u8FC7\u6765`
443
+ );
444
+ }
445
+ }
446
+ options.encoding = "base64";
447
+ const data = renderTpl(options);
448
+ const unsupported = pickUnsupported(data);
449
+ if (unsupported.length > 0) {
450
+ warnOnce(
451
+ `unsupported:${unsupported.join(",")}`,
452
+ `\u4EE5\u4E0B\u9009\u9879\u5728 shotium \u4E0B\u65E0\u6548\uFF0C\u5DF2\u5FFD\u7565: ${unsupported.join("\u3001")}\uFF08\u5F15\u64CE\u4E0D\u5E26 JS \u8FD0\u884C\u65F6\uFF09`
453
+ );
454
+ }
455
+ const hasHeaders = !!data.headers && Object.keys(data.headers).length > 0;
456
+ const { target, allowFileAccess } = resolveTarget(data.file, config, hasHeaders);
457
+ const shot = toScreenshotOptions(data, config);
458
+ const sliceHeight = toSliceHeight(data.multiPage, config.autoMultiPageHeight);
459
+ if (sliceHeight > 0 && shot.type !== "png") {
460
+ warnOnce(
461
+ "multiPageType",
462
+ `\u5206\u7247\u6E32\u67D3\u53EA\u80FD\u8F93\u51FA png\uFF0C\u672C\u6B21\u5DF2\u628A ${shot.type} \u5207\u6362\u4E3A png`
463
+ );
464
+ shot.type = "png";
465
+ delete shot.quality;
466
+ }
467
+ const result = await engine.screenshot({ ...shot, file: target, allowFileAccess });
468
+ const image = result.image;
469
+ if (!image) throw new Error(`[${pluginName}] \u5F15\u64CE\u6CA1\u6709\u8FD4\u56DE\u56FE\u7247\u6570\u636E`);
470
+ if (data.path) {
471
+ fs2.mkdirSync(path2.dirname(path2.resolve(data.path)), { recursive: true });
472
+ fs2.writeFileSync(path2.resolve(data.path), image);
473
+ }
474
+ const name = path2.basename(data.file_name || data.file || "unknown");
475
+ const stats = config.logStats ? ` \u7F51\u7EDC: ${result.stats.requests}(\u7F13\u5B58 ${result.stats.fromCache}/\u5931\u8D25 ${result.stats.failed}) \u5F15\u64CE: ${result.stats.timing.total.toFixed(1)}ms` : "";
476
+ if (sliceHeight === 0) {
477
+ logger.info(
478
+ `[${RENDER_ID}][${name}] \u622A\u56FE\u5B8C\u6210 \u5927\u5C0F: ${logger.green(formatBytes(image.length))} \u8017\u65F6: ${logger.green(String(Date.now() - time))} ms${stats}`
479
+ );
480
+ return image.toString("base64");
481
+ }
482
+ const list = splitPng(image, Math.round(sliceHeight * (shot.scale ?? 1)), config.sliceCompression);
483
+ if (!list) {
484
+ warnOnce("split", "\u5F53\u524D\u56FE\u7247\u683C\u5F0F\u65E0\u6CD5\u5206\u7247\uFF0C\u5DF2\u6309\u6574\u5F20\u8FD4\u56DE");
485
+ return [image.toString("base64")];
486
+ }
487
+ logger.info(
488
+ `[${RENDER_ID}][${name}] \u5206\u7247\u622A\u56FE\u5B8C\u6210 ${list.length} \u5F20 \u5927\u5C0F: ${logger.green(formatBytes(image.length))} \u8017\u65F6: ${logger.green(String(Date.now() - time))} ms${stats}`
489
+ );
490
+ return list.map((item) => item.toString("base64"));
491
+ });
492
+ logger.info(
493
+ `${logger.violet(`[\u63D2\u4EF6:${pluginVersion}]`)} ${logger.green(pluginName)} \u521D\u59CB\u5316\u5B8C\u6210~`
494
+ );
495
+ };
496
+ main();
497
+
498
+ export { UNSUPPORTED_KEYS, pickUnsupported, readPngInfo, splitPng, toScreenshotOptions, toSliceHeight, toWaitUntil };
@@ -0,0 +1,14 @@
1
+ import { GetConfigResponse, ComponentConfig } from 'node-karin';
2
+
3
+ /** WebUI 提交上来的表单 */
4
+ type FormValue = Record<string, unknown>;
5
+ declare const webConfig: {
6
+ info: GetConfigResponse['info'];
7
+ components: () => ComponentConfig[];
8
+ save: (config: FormValue) => {
9
+ success: boolean;
10
+ message: string;
11
+ };
12
+ };
13
+
14
+ export { webConfig as default };
@@ -0,0 +1,162 @@
1
+ import { package_default, getConfig, defaultConfig, saveConfig } from './chunk-76RKNDUT.js';
2
+ import { components } from 'node-karin';
3
+
4
+ var toNumber = (value, fallback) => {
5
+ const num = Number(value);
6
+ return Number.isFinite(num) ? num : fallback;
7
+ };
8
+ var webConfig = {
9
+ info: {
10
+ id: package_default.name,
11
+ name: "shotium \u6E32\u67D3\u5668",
12
+ version: package_default.version,
13
+ description: package_default.description,
14
+ author: [
15
+ {
16
+ name: "sj817",
17
+ home: "https://github.com/sj817",
18
+ avatar: "https://github.com/sj817.png"
19
+ }
20
+ ],
21
+ icon: {
22
+ name: "camera",
23
+ size: 24,
24
+ color: "#0969da"
25
+ }
26
+ },
27
+ components: () => {
28
+ const config = getConfig();
29
+ return [
30
+ components.radio.group("mode", {
31
+ label: "\u5F15\u64CE\u8FD0\u884C\u65B9\u5F0F",
32
+ orientation: "horizontal",
33
+ description: "\u8FDB\u7A0B\u5185\u6700\u7701\u4E8B\uFF1B\u5B88\u62A4\u8FDB\u7A0B\u53EF\u4EE5\u8BA9 karin \u91CD\u542F\u65F6\u4E0D\u7528\u91CD\u65B0\u4ED8\u51B7\u542F\u52A8\u7684\u94B1",
34
+ defaultValue: config.mode,
35
+ radio: [
36
+ components.radio.create("inprocess", { label: "\u8FDB\u7A0B\u5185", value: "inprocess" }),
37
+ components.radio.create("daemon", { label: "\u5B88\u62A4\u8FDB\u7A0B", value: "daemon" })
38
+ ]
39
+ }),
40
+ components.radio.group("localAccess", {
41
+ label: "\u672C\u5730\u6587\u4EF6\u52A0\u8F7D\u65B9\u5F0F",
42
+ orientation: "horizontal",
43
+ description: "auto: \u9ED8\u8BA4 file\uFF0C\u5E26\u81EA\u5B9A\u4E49\u8BF7\u6C42\u5934\u65F6\u81EA\u52A8\u8D70 http\uFF1Bfile: \u76F4\u63A5 file:// \u6253\u5F00\uFF1Bhttp: \u501F\u7528 karin \u81EA\u8EAB\u7684 http \u670D\u52A1\u8F6C\u6210 http:// \u518D\u622A\u56FE",
44
+ defaultValue: config.localAccess,
45
+ radio: [
46
+ components.radio.create("auto", { label: "auto", value: "auto" }),
47
+ components.radio.create("file", { label: "file", value: "file" }),
48
+ components.radio.create("http", { label: "http", value: "http" })
49
+ ]
50
+ }),
51
+ components.divider.create("divider0"),
52
+ components.radio.group("type", {
53
+ label: "\u9ED8\u8BA4\u622A\u56FE\u683C\u5F0F",
54
+ orientation: "horizontal",
55
+ description: "\u8C03\u7528\u65B9\u6CA1\u6709\u6307\u5B9A\u683C\u5F0F\u65F6\u4F7F\u7528",
56
+ defaultValue: config.type,
57
+ radio: [
58
+ components.radio.create("png", { label: "png", value: "png" }),
59
+ components.radio.create("jpeg", { label: "jpeg", value: "jpeg" }),
60
+ components.radio.create("webp", { label: "webp", value: "webp" })
61
+ ]
62
+ }),
63
+ components.input.number("quality", {
64
+ label: "\u9ED8\u8BA4\u538B\u7F29\u8D28\u91CF",
65
+ description: "1-100\uFF0C\u53EA\u5BF9 jpeg / webp \u751F\u6548",
66
+ defaultValue: String(config.quality)
67
+ }),
68
+ components.input.number("viewportWidth", {
69
+ label: "\u9ED8\u8BA4\u89C6\u7A97\u5BBD\u5EA6",
70
+ description: "\u5355\u4F4D css \u50CF\u7D20",
71
+ defaultValue: String(config.viewport.width)
72
+ }),
73
+ components.input.number("viewportHeight", {
74
+ label: "\u9ED8\u8BA4\u89C6\u7A97\u9AD8\u5EA6",
75
+ description: "\u5355\u4F4D css \u50CF\u7D20",
76
+ defaultValue: String(config.viewport.height)
77
+ }),
78
+ components.input.number("scale", {
79
+ label: "\u9ED8\u8BA4\u8BBE\u5907\u50CF\u7D20\u6BD4",
80
+ description: "0.01-8\uFF0C\u76F8\u5F53\u4E8E puppeteer \u7684 deviceScaleFactor",
81
+ defaultValue: String(config.scale)
82
+ }),
83
+ components.input.number("timeout", {
84
+ label: "\u9ED8\u8BA4\u5BFC\u822A\u8D85\u65F6(ms)",
85
+ defaultValue: String(config.timeout)
86
+ }),
87
+ components.input.number("autoMultiPageHeight", {
88
+ label: "\u81EA\u52A8\u5206\u7247\u9AD8\u5EA6",
89
+ description: "\u8C03\u7528\u65B9\u4F20 multiPage: true \u65F6\uFF0C\u6BCF\u7247\u7684\u9AD8\u5EA6(css \u50CF\u7D20)",
90
+ defaultValue: String(config.autoMultiPageHeight)
91
+ }),
92
+ components.input.number("sliceCompression", {
93
+ label: "\u5206\u7247\u91CD\u65B0\u7F16\u7801\u7684\u538B\u7F29\u7EA7\u522B",
94
+ description: "0-9\u3002\u5206\u7247\u8981\u628A\u6574\u5F20\u56FE\u62C6\u5F00\u91CD\u538B\u4E00\u904D\uFF0C\u7EA7\u522B\u8D8A\u4F4E\u8D8A\u5FEB\u3001\u4F53\u79EF\u8D8A\u5927\uFF0C3 \u662F\u6298\u4E2D\u503C",
95
+ defaultValue: String(config.sliceCompression)
96
+ }),
97
+ components.divider.create("divider1"),
98
+ components.input.string("cacheDir", {
99
+ label: "HTTP \u7F13\u5B58\u76EE\u5F55",
100
+ description: "\u7559\u7A7A\u4F7F\u7528\u5F15\u64CE\u9ED8\u8BA4\u76EE\u5F55\uFF1B\u586B off \u8868\u793A\u5173\u95ED\u7F13\u5B58",
101
+ defaultValue: config.cacheDir,
102
+ isRequired: false
103
+ }),
104
+ components.input.number("cacheMaxBytes", {
105
+ label: "HTTP \u7F13\u5B58\u4E0A\u9650(\u5B57\u8282)",
106
+ defaultValue: String(config.cacheMaxBytes)
107
+ }),
108
+ components.input.string("userAgent", {
109
+ label: "\u81EA\u5B9A\u4E49 UA",
110
+ description: "\u7559\u7A7A\u4F7F\u7528\u5F15\u64CE\u5185\u7F6E UA",
111
+ defaultValue: config.userAgent,
112
+ isRequired: false
113
+ }),
114
+ components.input.number("idleTimeoutMs", {
115
+ label: "\u5B88\u62A4\u8FDB\u7A0B\u7A7A\u95F2\u9000\u51FA(ms)",
116
+ description: "0 \u8868\u793A\u4E0D\u9000\u51FA\uFF0C\u4EC5\u5B88\u62A4\u8FDB\u7A0B\u6A21\u5F0F\u751F\u6548",
117
+ defaultValue: String(config.idleTimeoutMs)
118
+ }),
119
+ components.switch.create("logStats", {
120
+ label: "\u6253\u5370\u5F15\u64CE\u8017\u65F6\u7EDF\u8BA1",
121
+ description: "\u6BCF\u6B21\u622A\u56FE\u5728\u65E5\u5FD7\u91CC\u5E26\u4E0A\u8BF7\u6C42\u6570\u4E0E\u5F15\u64CE\u8017\u65F6",
122
+ defaultSelected: config.logStats,
123
+ color: "success"
124
+ })
125
+ ];
126
+ },
127
+ save: (form) => {
128
+ try {
129
+ const current = getConfig();
130
+ const cacheDir = String(form.cacheDir ?? "").trim();
131
+ const next = {
132
+ mode: form.mode || current.mode,
133
+ localAccess: form.localAccess || current.localAccess,
134
+ type: form.type || current.type,
135
+ quality: toNumber(form.quality, current.quality),
136
+ viewport: {
137
+ width: toNumber(form.viewportWidth, current.viewport.width),
138
+ height: toNumber(form.viewportHeight, current.viewport.height)
139
+ },
140
+ scale: toNumber(form.scale, current.scale),
141
+ timeout: toNumber(form.timeout, current.timeout),
142
+ autoMultiPageHeight: toNumber(form.autoMultiPageHeight, defaultConfig.autoMultiPageHeight),
143
+ sliceCompression: Math.min(9, Math.max(0, toNumber(form.sliceCompression, defaultConfig.sliceCompression))),
144
+ cacheDir,
145
+ cacheMaxBytes: toNumber(form.cacheMaxBytes, current.cacheMaxBytes),
146
+ userAgent: String(form.userAgent ?? ""),
147
+ idleTimeoutMs: toNumber(form.idleTimeoutMs, current.idleTimeoutMs),
148
+ logStats: form.logStats !== false
149
+ };
150
+ saveConfig(next);
151
+ return { success: true, message: "\u4FDD\u5B58\u6210\u529F" };
152
+ } catch (error) {
153
+ return {
154
+ success: false,
155
+ message: error instanceof Error ? error.message : String(error)
156
+ };
157
+ }
158
+ }
159
+ };
160
+ var web_config_default = webConfig;
161
+
162
+ export { web_config_default as default };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@karinjs/plugin-shotium",
3
- "version": "0.0.1",
4
- "description": "Placeholder release to reserve the package name for Trusted Publishing.",
5
- "license": "MIT"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "karin shotium 截图、渲染插件",
5
+ "keywords": [
6
+ "karin",
7
+ "node-karin",
8
+ "karin-plugin",
9
+ "shotium",
10
+ "screenshot",
11
+ "render"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "sj817",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/KarinJS/plugin-shotium.git"
24
+ },
25
+ "dependencies": {
26
+ "@shotkit/shotium": "^0.3.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/express": "5.0.6",
30
+ "@types/node": "^22.19.15",
31
+ "eslint": "^9.7.0",
32
+ "neostandard": "^0.12.1",
33
+ "node-karin": "^1.17.0",
34
+ "tsup": "^8.5.1",
35
+ "tsx": "^4.19.3",
36
+ "typescript": "^5.8.2",
37
+ "vitest": "^4.1.8"
38
+ },
39
+ "karin": {
40
+ "main": "src/index.ts",
41
+ "apps": [],
42
+ "web": "dist/web.config.js",
43
+ "ts-web": "src/web.config.ts",
44
+ "files": [
45
+ "config"
46
+ ]
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc && tsup",
54
+ "dev": "tsx src/app.ts",
55
+ "lint": "eslint src/",
56
+ "pub": "pnpm publish --access public",
57
+ "rm": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
58
+ "sync": "curl -X PUT \"https://registry-direct.npmmirror.com/-/package/@karinjs/plugin-shotium/syncs\"",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest"
61
+ }
62
+ }