注意

以下的教程都建立在屏幕分辨率为 4K 的情况下,截图时会导致图像很大

截图工具

首先,我们切换截图工具,不再使用传统的 alt + ctrl + awin + shift + s

我们在微软商店中下载 Snipaste,如下图所示:

image.png|350

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

字段默认值说明
thresholdKB1024小于等于该体积(KiB)的图片不压缩
targetKB1024压缩目标体积,不含 HTTP/base64 传输开销
quality90JPEG/WebP 初始质量
minQuality85不再低于此质量继续尝试
qualityStep10每轮质量下调步长
maxLongEdge0首轮最长边上限;0 表示不缩放
minLongEdge1280进一步缩放时的长边下限,不放大小图
resizeRatio0.8未达标时,下一轮长边乘以此比例
maxAttempts12单张编码次数上限
convertToWebpfalse是否统一转成 WebPObsidian 无法显示 WebP,建议保持 false
strictMaxSizefalse严格模式,超过 targetKB 的文件不上传
maxInputMP60超过 6000 万像素不尝试解码
maxInputMB100超过 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
}