图片格式转换:WebP/TIFF 批量转 JPG
把文件夹里的 WebP、TIFF、SVG、PNG 批量转成 JPG 或 PNG。默认先预览每张的尺寸和体积,确认后加 --write 输出到 out/,原图不动。
本页目录
30 秒速查
- 图片放
input/,先跑预览,不写盘- 预览:
node resources/convert-images/convert.mjs input- 要透明背景:加
--to png- 确认后加
--write,输出到input/out/- 原图不动,重复跑不会叠着压
这篇教程解决什么问题
同一个商品图,设计给的是 PNG,相机导出的是 TIFF,网页另存下来是 WebP,平台后台却只收 JPG。一张张打开另存,二十张就要半小时。
这篇用一个脚本批量转:支持 WebP、TIFF、SVG、PNG,统一输出 JPG 或 PNG,默认只预览,确认后写入单独的 out/,原图不动。
装好 Sharp
图片解码和编码用 Sharp,它底层是 libvips,不依赖系统里的图像软件。
在站点仓库里,根目录已经装过 Sharp,直接跑就行。想单独放一个目录,先初始化再装:
mkdir image-convert && cd image-convert
npm init -y
npm install sharp
脚本按 Node 20 以上写,先确认版本:
node -v
建两个文件
在 resources/convert-images/ 下放两个文件:core.mjs 负责收集、转换、写盘;convert.mjs 只负责读参数和打印结果。
convert-images/
├── core.mjs
└── convert.mjs
分开的原因很实际:核心逻辑可以被测试直接 import,命令入口只做参数解析,两边都好改。
写核心模块
保存为 resources/convert-images/core.mjs:
import sharp from "sharp";
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
export const SUPPORTED = [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".svg"];
export const QUALITY = 82;
export async function collectImages(target) {
const info = await stat(target);
if (!info.isDirectory()) return [target];
const entries = await readdir(target, { withFileTypes: true });
return entries
.filter(
(entry) =>
entry.isFile() &&
!entry.name.startsWith(".") &&
SUPPORTED.includes(path.extname(entry.name).toLowerCase()),
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => path.join(target, entry.name));
}
export function outputName(file, format) {
const ext = format === "jpeg" ? "jpg" : format;
return `${path.basename(file, path.extname(file))}.${ext}`;
}
export async function convertFile(
file,
{ format = "jpg", quality = QUALITY, write = false, outDir } = {},
) {
const before = (await stat(file)).size;
const source = sharp(file).rotate();
const metadata = await source.metadata();
const pipeline =
format === "png"
? source.png({ compressionLevel: 9 })
: source.flatten({ background: "#ffffff" }).jpeg({ quality, mozjpeg: true });
const buffer = await pipeline.toBuffer();
const target = path.join(outDir, outputName(file, format));
if (write) {
await mkdir(outDir, { recursive: true });
await writeFile(target, buffer);
}
return {
file,
target,
before,
after: buffer.length,
width: metadata.width,
height: metadata.height,
written: write,
};
}
export async function convertAll(target, options = {}) {
const format = options.format ?? "jpg";
const files = await collectImages(target);
const base = path.extname(target) ? path.dirname(target) : target;
const outDir = options.outDir ?? path.join(base, "out");
const names = new Set();
for (const file of files) {
const name = outputName(file, format).toLowerCase();
if (names.has(name)) {
throw new Error(`输出重名:${name}。先改名,或分到两个目录再转。`);
}
names.add(name);
}
const results = [];
for (const file of files) {
results.push(await convertFile(file, { ...options, format, outDir }));
}
return results;
}
关键只有三处:
sharp(file).rotate():手机照片带方向信息,先转正再编码;flatten({ background: "#ffffff" }):JPG 没有透明通道,先把透明区域铺白;outputName():只换扩展名,文件名保持不变,方便和原图对照。
convertAll 还会先检查输出是否重名:photo.png 和 photo.webp 都会变成 photo.jpg,不拦就会悄悄覆盖一张。
写命令入口
保存为 resources/convert-images/convert.mjs:
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { QUALITY, convertAll } from "./core.mjs";
function kb(bytes) {
return Math.max(1, Math.round(bytes / 1024)) + " KB";
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const { values, positionals } = parseArgs({
options: {
to: { type: "string", default: "jpg" },
quality: { type: "string", default: String(QUALITY) },
out: { type: "string" },
write: { type: "boolean", default: false },
},
allowPositionals: true,
});
const target = positionals[0] ?? "input";
const format = values.to.toLowerCase();
if (!["jpg", "jpeg", "png"].includes(format)) {
console.error("--to 只支持 jpg 或 png。");
process.exitCode = 1;
} else {
try {
const results = await convertAll(target, {
format,
quality: Number(values.quality),
write: values.write,
outDir: values.out,
});
if (results.length === 0) {
console.error("没有找到可转换的图片。");
process.exitCode = 1;
} else {
const flag = values.write ? "已转换" : "预览";
for (const item of results) {
console.log(
`[${flag}] ${path.basename(item.file)} → ${path.basename(item.target)} ` +
`${item.width}×${item.height} ${kb(item.before)} → ${kb(item.after)}`,
);
}
console.log(`\n共 ${results.length} 张,统一输出 ${format}。`);
console.log(
values.write
? `输出目录:${path.relative(process.cwd(), path.dirname(results[0].target))}`
: "这是预览,没有写文件。确认后加 --write。",
);
}
} catch (error) {
console.error(error.code === "ENOENT" ? `打不开路径:${target}` : error.message);
process.exitCode = 1;
}
}
}
先预览

准备四张不同格式的测试图放进 input/:一张 WebP 照片、一张带透明的 PNG、一张 TIFF 扫描件、一张 SVG 图标。
node resources/convert-images/convert.mjs input
真实输出:
[预览] badge.svg → badge.jpg 480×320 1 KB → 4 KB
[预览] logo-alpha.png → logo-alpha.jpg 800×800 15 KB → 2 KB
[预览] photo.webp → photo.jpg 900×600 1 KB → 2 KB
[预览] scan.tiff → scan.jpg 1000×700 12 KB → 2 KB
共 4 张,统一输出 jpg。
这是预览,没有写文件。确认后加 --write。
预览会真的编码一遍,但只放在内存里。体积变化不合理时,先别写盘。
写入
node resources/convert-images/convert.mjs input --write
[已转换] badge.svg → badge.jpg 480×320 1 KB → 4 KB
[已转换] logo-alpha.png → logo-alpha.jpg 800×800 15 KB → 2 KB
[已转换] photo.webp → photo.jpg 900×600 1 KB → 2 KB
[已转换] scan.tiff → scan.jpg 1000×700 12 KB → 2 KB
共 4 张,统一输出 jpg。
输出目录:input/out
input/out/ 里是四个 JPG,文件名和原图一一对应。原图还在,脚本不删任何东西。

需要透明背景时输出 PNG

node resources/convert-images/convert.mjs input --to png --out input/png --write
[已转换] badge.svg → badge.png 480×320 1 KB → 5 KB
[已转换] logo-alpha.png → logo-alpha.png 800×800 15 KB → 4 KB
[已转换] photo.webp → photo.png 900×600 1 KB → 3 KB
[已转换] scan.tiff → scan.png 1000×700 12 KB → 3 KB
共 4 张,统一输出 png。
输出目录:input/png
PNG 保留透明通道,适合图标、Logo、贴纸。照片仍然建议 JPG,PNG 会让体积变大。
参数怎么调
| 参数 | 默认 | 说明 |
|---|---|---|
--to |
jpg |
只支持 jpg 或 png |
--quality |
82 |
JPG 质量,1–100 |
--out |
输入目录/out |
输出目录 |
--write |
关 | 不加只预览 |
质量 82 是清晰度和体积的平衡点。要更小调到 75;要更清楚调到 90,但体积会明显上来。
常见坑
1. SVG 里的文字变方框
Sharp 渲染 SVG 时用的是系统字体。SVG 里写了苹方或微软雅黑,跑到另一台机器上可能没有,就会变成方框。把文字转成路径再导出,最稳。
2. TIFF 报「unsupported image format」
Sharp 的预编译包通常带 TIFF 支持,但自己编译的 libvips 可能没开。先跑一张确认,报错就换官方预编译版本,或先用系统工具转成 PNG。
3. 同一批图里有 GIF
脚本会跳过 GIF,不会把它当静态图转。GIF 动图转 JPG 只剩第一帧,需要动图就保留原文件。
4. 输出目录放在输入目录里
默认输出到 input/out/,脚本只扫描 input/ 第一层,不会把 out/ 里的结果再转一遍。手动指定 --out 时也别指向输入目录本身。
5. 转成 JPG 反而变大了
这不是幻觉。同一批样本实测:IMG_3004.webp 39 KB → 57 KB,IMG_3001.webp 60 KB → 76 KB。WebP 本身压得更狠,把它重新编码成质量 80 的 JPG,体积反而会上去。
所以判断很简单:对方打不开 WebP、TIFF,才需要转;纯粹为了省空间,不要转格式。 省空间靠批量压缩图片把最长边和画质一起降下来,不是靠换后缀。
下一步读什么
网页图转完,下一步是压体积:批量压缩图片 把最长边缩到 1600px,上传更快。
如果是 PDF 里的页面要变成图片,看PDF 每页转 PNG。
这条路线收在文件转换合集里。
常见问题
会覆盖原图吗?
不会。脚本只读输入目录,结果写到 input/out/;加 --write 才写盘,之前只打印每张图的尺寸和体积。
转成 JPG 后透明背景怎么变白了?
JPG 不支持透明。脚本会先铺一层白底,避免透明区域变黑。需要保留透明,就加 --to png,输出 PNG。
为什么 SVG 转出来是 480×320,不是更大?
SVG 没有固定像素,Sharp 默认按 72 dpi 渲染,480×320 是文件里写的尺寸。要更大,先在设计软件里把画布调大,或改用 PNG 源图。
GIF 和 HEIC 能转吗?
不能。脚本只收 jpg、jpeg、png、webp、tif、tiff、svg。GIF 会动,转成静态图只剩第一帧;HEIC 需要额外的解码库。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。