注意
以下的教程都建立在屏幕分辨率为
4K的情况下,截图时会导致图像很大
截图工具
首先,我们切换截图工具,不再使用传统的 alt + ctrl + a 与 win + shift + s
我们在微软商店中下载 Snipaste,如下图所示:

Tip
如果你使用 Mac,也可以使用
HomeBrew下载这个软件,然后将截图键设置为Option + Q(我的设置)
这个应用能够使得截图时不已分辨率为单位,而是选中的区域多大,像素长宽就是多大,此后,我们只需要按 F1 进行截图即可
Hint
值得一提的是这个工具还有很多其他好用功能,可以自行探索
PicList 的自定义脚本
但只有上面的截图工具还不够,我们还可以进一步压缩。此前我使用的是 压缩插件,但它在 PicList 版本升级后已经无法正常使用,因此我改用 PicList 自带的脚本系统,在「上传前」阶段做一次本地压缩。
关于旧方案
旧插件(
picgo-plugin-compress-next)的教程已存档在 这里。
为什么改用脚本
PicList 从 v3.3.0 起引入了脚本系统,可以直接在软件里编写或加载 JS 脚本,不需要本地安装 Node.js,也不需要像插件那样经过打包、签名等步骤。脚本按执行时机(生命周期)分类,其中「上传前 / beforeUpload」恰好对应旧插件挂载的钩子,因此在图片真正发送到图床之前,我们可以对 ctx.output 里的图片数据做一次压缩。
安装
在 PicList 中打开「脚本」页面,即可看到这个「上传前」脚本,启用它即可生效;也可以直接在脚本页编辑代码,修改最上方的 CONFIG 配置块来调整压缩策略。
Tip
脚本复用了 PicList 内部的
compressImage压缩引擎,因此无需再装任何压缩插件。但该接口不是官方稳定 API,升级后可能失效——脚本里已经加了运行时检查,失效时会在日志中提示,而不会中断上传。
脚本做了什么
概括地说,脚本会在上传前遍历 ctx.output 中的每一张图,按体积阈值决定是否压缩:
- 只处理
JPG / PNG / WebP,并跳过动画(APNG / WebP动画),保留所有帧; - 根据文件头(而非后缀)判断真实格式,读取
EXIF方向以便压缩时正确旋转; - 以
CONFIG中的目标体积(targetKB)为准,逐轮降低质量、缩放长边,直到达到目标或达到尝试上限; - 若压缩结果比原图还大,则保留原图;开启
strictMaxSize后,仍超过目标体积的文件会被从上传列表中剔除。
配置项
日常只需要改脚本最上方的 CONFIG:
| 字段 | 默认值 | 说明 |
|---|---|---|
thresholdKB | 1024 | 小于等于该体积(KiB)的图片不压缩 |
targetKB | 1024 | 压缩目标体积,不含 HTTP/base64 传输开销 |
quality | 90 | JPEG/WebP 初始质量 |
minQuality | 85 | 不再低于此质量继续尝试 |
qualityStep | 10 | 每轮质量下调步长 |
maxLongEdge | 0 | 首轮最长边上限;0 表示不缩放 |
minLongEdge | 1280 | 进一步缩放时的长边下限,不放大小图 |
resizeRatio | 0.8 | 未达标时,下一轮长边乘以此比例 |
maxAttempts | 12 | 单张编码次数上限 |
convertToWebp | false | 是否统一转成 WebP;Obsidian 无法显示 WebP,建议保持 false |
strictMaxSize | false | 严格模式,超过 targetKB 的文件不上传 |
maxInputMP | 60 | 超过 6000 万像素不尝试解码 |
maxInputMB | 100 | 超过 100 MiB 不尝试解码 |
缺点
因为压缩在本机完成,所以大图上传会比直接传更慢(会卡在压缩这一步),这是本地压缩的固有代价。
Attention
首次使用建议先用非重要图片试传,确认你的图床接受压缩后的格式与大小。
脚本内容
/**
* @name local-compress
* @author ChatGPT
* @description 上传前本地压缩 JPG/PNG/WebP;按体积触发,保留源文件与动画,可选严格大小限制。
* @version 1.0.0
*
* 安装阶段:beforeUpload(上传前),不是 preProcess、upload 或手动触发。
* 无需 require、Node.js 安装或外部压缩服务。本文件不写磁盘、不发网络请求。
*
* 兼容性提醒:复用 ctx.pluginHandler.ctx.lifecycle.compressImage 内部接口。
* 该入口不是官方稳定脚本 API;升级后可能失效。下方有运行时检查。
* 参考:Kuingsmile/PicList-Core src/core/Lifecycle.ts、src/lib/PluginHandler.ts、
* src/utils/createContext.ts、src/utils/common/compress.ts(2026-09-16 查阅)。
*
* 默认是有损、尽力压缩,并非保证每张图片小于目标值。
* strictMaxSize=true 会从当前上传列表中排除仍超限或无法确认大小的文件。
* 首次使用请用非重要图片试传,确认你所用图床/插件接受 WebP。
*/
// ===================== 日常只需修改这里 =====================
const CONFIG = Object.freeze({
thresholdKB: 1024, // <= 此体积不压缩,单位 KiB(1024 字节)
targetKB: 1024, // 压缩目标,不含 HTTP/base64 传输开销
quality: 85, // JPEG/WebP 初始质量;不是百分比文件大小
minQuality: 65, // 不低于这个质量继续尝试
qualityStep: 10,
maxLongEdge: 2560, // 大图首轮最长边;0 = 全程不缩放
minLongEdge: 1280, // 进一步缩放时的长边下限,不放大小图
resizeRatio: 0.8, // 未达目标时,下一轮长边乘以此比例
maxAttempts: 12, // 单张编码次数上限
convertToWebp: true, // false = JPG/PNG/WebP 各自保留原格式
strictMaxSize: false, // true = 超过 targetKB 的文件不上传,包括动画/不支持格式
maxInputMP: 60, // 超过 6000 万像素不尝试解码,避免内存压力
maxInputMB: 100, // 超过 100 MiB 不尝试解码
})
// ==========================================================
function log(ctx, level, text) {
const fn = ctx.log && (ctx.log[level] || ctx.log.info)
if (typeof fn === 'function') fn.call(ctx.log, `[local-compress] ${text}`)
}
function sizeText(bytes) {
return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(2)} MiB` : `${(bytes / 1024).toFixed(1)} KiB`
}
function validateConfig() {
for (const key of ['thresholdKB', 'targetKB', 'maxInputMP', 'maxInputMB']) {
if (!Number.isFinite(CONFIG[key]) || CONFIG[key] <= 0) throw new Error(`${key} 必须为正数`)
}
for (const key of ['quality', 'minQuality', 'qualityStep', 'maxAttempts']) {
if (!Number.isInteger(CONFIG[key]) || CONFIG[key] < 1) throw new Error(`${key} 必须为正整数`)
}
if (CONFIG.quality > 100 || CONFIG.minQuality > CONFIG.quality) throw new Error('质量参数无效')
if (!Number.isInteger(CONFIG.maxLongEdge) || CONFIG.maxLongEdge < 0 ||
!Number.isInteger(CONFIG.minLongEdge) || CONFIG.minLongEdge < 1 ||
(CONFIG.maxLongEdge > 0 && CONFIG.minLongEdge > CONFIG.maxLongEdge)) {
throw new Error('长边参数无效')
}
if (!(CONFIG.resizeRatio > 0 && CONFIG.resizeRatio < 1)) throw new Error('resizeRatio 必须在 0 和 1 之间')
}
function getBuffer(img) {
if (Buffer.isBuffer(img.buffer)) return img.buffer
if (typeof img.base64Image === 'string' && img.base64Image.length) {
const value = img.base64Image.replace(/^data:[^,]*;base64,/i, '')
return Buffer.from(value, 'base64')
}
return null
}
// 读取 EXIF Orientation;只读有界 TIFF 数据,不执行或加载任何外部内容。
function readOrientation(buf, start, end) {
if (buf.toString('ascii', start, Math.min(start + 6, end)) === 'Exif\0\0') start += 6
if (start + 8 > end) return 1
const order = buf.toString('ascii', start, start + 2)
if (order !== 'II' && order !== 'MM') return 1
const u16 = p => order === 'II' ? buf.readUInt16LE(p) : buf.readUInt16BE(p)
const u32 = p => order === 'II' ? buf.readUInt32LE(p) : buf.readUInt32BE(p)
if (u16(start + 2) !== 42) return 1
const ifd = start + u32(start + 4)
if (ifd < start + 8 || ifd + 2 > end) return 1
const count = u16(ifd)
for (let i = 0; i < count; i++) {
const p = ifd + 2 + i * 12
if (p + 12 > end) break
if (u16(p) === 0x0112 && u16(p + 2) === 3 && u32(p + 4) === 1) {
const value = u16(p + 8)
return value >= 1 && value <= 8 ? value : 1
}
}
return 1
}
// 使用实际文件头,而非仅信任后缀;同时检测动画,避免将动画压成单帧。
function inspect(buf) {
const info = { format: '', width: 0, height: 0, animated: false, orientation: 1 }
if (buf.length >= 24 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
info.format = 'png'
info.width = buf.readUInt32BE(16)
info.height = buf.readUInt32BE(20)
for (let p = 8; p + 12 <= buf.length;) {
const len = buf.readUInt32BE(p)
const end = p + 12 + len
if (end > buf.length) throw new Error('PNG 数据不完整')
const type = buf.toString('ascii', p + 4, p + 8)
if (type === 'acTL') info.animated = true
if (type === 'eXIf') info.orientation = readOrientation(buf, p + 8, end - 4)
p = end
if (type === 'IEND') break
}
} else if (buf.length >= 4 && buf[0] === 255 && buf[1] === 216) {
info.format = 'jpeg'
for (let p = 2; p + 1 < buf.length;) {
if (buf[p++] !== 255) break
while (p < buf.length && buf[p] === 255) p++
const marker = buf[p++]
if (marker === 0xda || marker === 0xd9) break
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue
if (p + 2 > buf.length) break
const len = buf.readUInt16BE(p)
const end = p + len
if (len < 2 || end > buf.length) throw new Error('JPEG 数据不完整')
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker) && len >= 8) {
info.height = buf.readUInt16BE(p + 3)
info.width = buf.readUInt16BE(p + 5)
}
if (marker === 0xe1 && buf.toString('ascii', p + 2, p + 8) === 'Exif\0\0') {
info.orientation = readOrientation(buf, p + 2, end)
}
p = end
}
} else if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
info.format = 'webp'
for (let p = 12; p + 8 <= buf.length;) {
const type = buf.toString('ascii', p, p + 4)
const len = buf.readUInt32LE(p + 4)
const d = p + 8
const end = d + len
if (end > buf.length) throw new Error('WebP 数据不完整')
if (type === 'ANIM' || type === 'ANMF') info.animated = true
if (type === 'VP8X' && len >= 10) {
info.animated = info.animated || !!(buf[d] & 2)
info.width = buf.readUIntLE(d + 4, 3) + 1
info.height = buf.readUIntLE(d + 7, 3) + 1
} else if (type === 'VP8 ' && len >= 10 && !info.width) {
info.width = buf.readUInt16LE(d + 6) & 0x3fff
info.height = buf.readUInt16LE(d + 8) & 0x3fff
} else if (type === 'VP8L' && len >= 5 && !info.width) {
const bits = buf.readUInt32LE(d + 1)
info.width = (bits & 0x3fff) + 1
info.height = ((bits >>> 14) & 0x3fff) + 1
}
if (type === 'EXIF') info.orientation = readOrientation(buf, d, end)
p = end + (len & 1)
}
}
return info
}
function orientationOptions(value) {
// Sharp 在旋转之前应用 flip/flop;5/7 分别对应转置/反转置。
return {
isRotate: [3, 5, 6, 7, 8].includes(value),
rotateDegree: ({ 3: 180, 5: 90, 6: 90, 7: 90, 8: 270 })[value] || 0,
isFlip: value === 4 || value === 5,
isFlop: value === 2 || value === 7,
}
}
function resolveEngine(ctx) {
// 上传 ctx 是核心实例的浅包装;pluginHandler 保留了原始实例引用。
const host = ctx.pluginHandler && ctx.pluginHandler.ctx
const lifecycle = (host && host.lifecycle) || ctx.lifecycle
if (!lifecycle || typeof lifecycle.compressImage !== 'function') return null
return lifecycle.compressImage.bind(lifecycle)
}
function renamed(name, format) {
const ext = path.extname(name)
const existing = ext.toLowerCase()
if (format === 'jpeg' && ['.jpg', '.jpeg'].includes(existing)) return name
if (existing === `.${format}`) return name
return `${ext ? name.slice(0, -ext.length) : name}.${format === 'jpeg' ? 'jpg' : format}`
}
async function compressOne(ctx, img, engine) {
const original = getBuffer(img)
if (!original || !original.length) throw new Error('缺少可用的上传 buffer/base64Image')
if (original.length <= CONFIG.thresholdKB * 1024) return
if (original.length > CONFIG.maxInputMB * 1048576) throw new Error('超过解码文件大小保护限制')
const meta = inspect(original)
if (!meta.format) {
log(ctx, 'info', `${img.fileName || '(未命名)'}:跳过非 JPG/PNG/WebP 文件`)
return
}
if (meta.animated) {
log(ctx, 'info', `${img.fileName}:跳过动画,保留全部帧`)
return
}
if (!(meta.width > 0 && meta.height > 0)) throw new Error('无法确定图片尺寸')
if (meta.width * meta.height > CONFIG.maxInputMP * 1000000) throw new Error('超过解码像素数保护限制')
if (typeof img.fileName !== 'string' || !img.fileName) throw new Error('缺少上传文件名')
const outFormat = CONFIG.convertToWebp ? 'webp' : meta.format
const inputExtension = meta.format === 'jpeg' ? '.jpg' : `.${meta.format}`
const sourceEdge = Math.max(meta.width, meta.height)
const firstEdge = CONFIG.maxLongEdge > 0 ? Math.min(sourceEdge, CONFIG.maxLongEdge) : sourceEdge
const floorEdge = Math.min(firstEdge, CONFIG.minLongEdge)
const qualities = []
for (let q = CONFIG.quality; q > CONFIG.minQuality; q -= CONFIG.qualityStep) qualities.push(q)
qualities.push(CONFIG.minQuality)
let best = original
let bestMeta = meta
let attempts = 0
let edge = firstEdge
let achieved = false
while (attempts < CONFIG.maxAttempts) {
// 极窄长图至少保留一个像素,防止核心缩放将短边四舍五入为 0。
const percent = Math.min(100, Math.max(edge / sourceEdge * 100, 100 / Math.min(meta.width, meta.height)))
for (const quality of qualities) {
if (attempts++ >= CONFIG.maxAttempts) break
const options = {
quality,
// 当前内核 isConvert=true 且源/目标格式相同时可能跳过质量设置。
isConvert: outFormat !== meta.format,
convertFormat: outFormat,
formatConvertObj: {},
isReSize: false,
isReSizeByPercent: percent < 100,
reSizePercent: percent,
...orientationOptions(meta.orientation),
}
// 白名单只含 JPG/PNG/WebP,不会进入核心的 HEIC 临时文件写入分支。
// 每轮从原始 buffer 编码,而不是反复压缩上一轮的有损结果。
const candidate = await engine(
original, undefined, inputExtension, options, img.fileName,
path.join(ctx.baseDir, 'piclistTemp'), ctx,
)
if (!Buffer.isBuffer(candidate) || !candidate.length) throw new Error('压缩引擎返回无效数据')
const actual = inspect(candidate)
if (actual.format !== outFormat || !actual.width || !actual.height || actual.animated) {
throw new Error('压缩输出格式或尺寸不符合预期;保留未修改的数据')
}
if (candidate.length < best.length) {
best = candidate
bestMeta = actual
}
if (best.length <= CONFIG.targetKB * 1024) { achieved = true; break }
}
if (achieved || CONFIG.maxLongEdge === 0 || edge <= floorEdge) break
edge = Math.max(floorEdge, Math.floor(edge * CONFIG.resizeRatio))
}
if (best === original) {
log(ctx, 'info', `${img.fileName}:未得到更小的结果,保留 ${sizeText(original.length)}`)
return
}
const oldName = img.fileName
const newName = renamed(oldName, bestMeta.format)
// 先计算所有新值,再更新本次上传的数据;不触碰 ctx.input 对应的磁盘文件。
const newBase64 = typeof img.base64Image === 'string' ? best.toString('base64') : null
img.buffer = best
if (newBase64 !== null) img.base64Image = newBase64
img.fileName = newName
img.extname = path.extname(newName)
img.width = bestMeta.width
img.height = bestMeta.height
if ('fileSize' in img) img.fileSize = best.length
if (typeof img.size === 'number') img.size = best.length
if ('mimeType' in img) img.mimeType = `image/${bestMeta.format}`
log(ctx, 'info', `${oldName} -> ${newName}:${sizeText(original.length)} -> ${sizeText(best.length)},` +
`减少 ${(100 * (1 - best.length / original.length)).toFixed(1)}%,${bestMeta.width}×${bestMeta.height}`)
}
function checkFinalSizes(ctx) {
const accepted = []
for (const img of ctx.output) {
let data = null
try { data = getBuffer(img) } catch (_) { /* 下方按未知大小处理。 */ }
const tooLarge = !data || data.length > CONFIG.targetKB * 1024
if (tooLarge) {
const detail = data ? sizeText(data.length) : '大小无法确认'
log(ctx, 'warn', `${img.fileName || '(未命名)'}:${detail};` +
(CONFIG.strictMaxSize ? '已从本次上传列表排除' : '仍将按当前数据上传(未保证达到目标)'))
}
if (!CONFIG.strictMaxSize || !tooLarge) accepted.push(img)
}
// 不靠 throw 阻止上传:PicList 会记录并吞掉生命周期脚本异常。
if (CONFIG.strictMaxSize) ctx.output = accepted
}
async function main(ctx, extra) {
if (!ctx || !Array.isArray(ctx.output)) throw new Error('需要 PicList 上传上下文')
validateConfig()
if (!ctx.output.length) {
log(ctx, 'info', '没有待处理数据。请将脚本启用在“上传前 / beforeUpload”,然后实际上传图片。')
return ctx
}
const engine = resolveEngine(ctx)
if (!engine) {
log(ctx, 'error', '当前版本未暴露内部 compressImage 接口,未执行压缩;请改用内置图片预处理或停用此脚本。')
} else {
// 逐张 await,不并行解码一整批大图。
for (const img of ctx.output) {
try { await compressOne(ctx, img, engine) }
catch (error) { log(ctx, 'warn', `${img.fileName || '(未命名)'}:${error.message || error};未修改该文件的上传数据`) }
}
}
checkFinalSizes(ctx)
return ctx
}