背景
本站博客的架构中,博文来源包括了远程 CMS 平台(Sanity),除了用作博文的存储平台,也可以用作为图床,而该平台返回的图床信息中,包括了"palette"(调色板) 字段,其下包括从图片提取的几种主色调,以及与之相配的前景色,json 结构如下:
而近期对博客重构升级中,吾辈拟对本地博文(指 markdown 文档存储在博客源码同目录下)也支持图片封面 + 提取配套前景背景色方案
正文
先抬出下面几个必要链接,方便你的跳转:
- 一个基于 NodeJS 的开源项目:node-vibrant
- node-vibrant 官网
- [Feature] webp support #44
吾辈一开始本地安装导入 node-vibrant@latest
, 再编写额外代码,用于读取指定路径下图片,不过很快遇到报错,即不能解析比如 webp 格式的图片(的配色方案),
而后再网络搜索,便找到上面的 Issue 链接,该链接下的评论回复给出重要信息和一些代码段分享,总而言之,依然可以继续使用 node-vibrant 这个程序,但是它本身为了最大兼容性只支持有限的图片格式,但是,允许开发者自行拓展。
node-vibrant is designed to be extendable. Image format support is provided via ImageClasss. One can implement his/her own ImageClass and use it by set Vibrant.DefaultOpts.ImageClass = YourCustomImageClass.
node-vibrant 的设计是可扩展的。图像格式支持通过 ImageClass 提供。用户可以实现自己的 ImageClass 并通过设置 Vibrant.DefaultOpts.ImageClass = YourCustomImageClass 来使用它。
而如何拓展可参考 Issue 下这个开发者评论, 当然社区还是有已发布的 NPM 包,吾辈最终选择了安装其他开发者基于 node-vibrant 的改动版本
另外,需要注意,node-vibrant 默认只提供背景色的配色方案,即如果需要完整的配色方案(即前景色 + 背景色),需要额外的算法支持。
下面给出该算法分享:
/**
* 根据背景色 RGB 值判断前景色是否应该使用白色
* 返回 true 表示应该使用白色,false 表示应该使用黑色
* @param r - 红色通道值 (0-255)
* @param g - 绿色通道值 (0-255)
* @param b - 蓝色通道值 (0-255)
*/
export function shouldUseLightText(r, g, b) {
// 将 RGB 值转换为相对亮度
// 根据 WCAG 2.0 规范:https://www.w3.org/TR/WCAG20/#relativeluminancedef
const toSRGB = (x) => {
x = x / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
};
const sR = toSRGB(r);
const sG = toSRGB(g);
const sB = toSRGB(b);
// 计算相对亮度 L
const L = 0.2126 * sR + 0.7152 * sG + 0.0722 * sB;
// 如果亮度小于 0.5,使用白色文本
// 如果亮度大于等于 0.5,使用黑色文本
return L < 0.5;
}
完整可用代码段
当然的,如果读者某一天也有类似的开发需求,吾辈在下面贴出先前自用的完整代码,能够满足一定的使用场景。(注意,下面代码中,相关目录路径需要复制后自行替换)
支持显式声明 tagFilterMode
字段,开启所谓的“增量更新”模式,免去每次解析重复的图片。
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Vibrant from "@behold/sharp-vibrant";
import { shouldUseLightText } from "./_shouldUseLightText.mjs";
const reg = /\.(?:webp|jpg|jpeg|png)$/; // ⚠️ avif not surpport at now.
const colorTypes = ["Vibrant", "LightVibrant", "DarkVibrant", "Muted", "LightMuted", "DarkMuted"];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
invoke({
imgPath: path.join(__dirname, "<path/to/you-imgs-file>"),
outFile: path.join(__dirname, "<path/to/your-json-file>"),
filterMode: true,
jsonMiniStyle: true,
});
/**
* @param {{filterMode: boolean, imgPath: string, outFile: string}}
* > filterMode : ⚠️ 支持过滤已经处理过的图片文件 | 通过该字段支持
* 🚩 flagMut = !filterMode || initFlag; //true-全覆盖模式
*/
async function invoke({
imgPath,
outFile,
filterMode,
jsonMiniStyle = false,
}) {
if (!fs.existsSync(imgPath))
throw new Error(`❌ 请确认图片文件夹合法:${imgPath}\n`);
/** @type Promise[] */
const ps = [];
const resMap = Object.create(null);
const { initFlag } = validatorOutDir$Eff(outFile);
const flagOverrides = initFlag || !filterMode;
// note: filterMode = false 情况下无必要再去读已有数据,后续总会全覆盖
const {
existingData,
alreadyKeysToImg,
} = getAlreadyDataFromOutFile({ outFile: flagOverrides ? null : outFile });
const needHdlThisImg = (imgName) => {
/** 业务出发,该函数改为非纯函数,利用闭包访问外部 filterMode, alreadyKeys */
if (flagOverrides)
return reg.test(imgName);
return reg.test(imgName) && !alreadyKeysToImg.includes(imgName);
};
const getFullData = (resMap) => {
if (!flagOverrides) {
return ({
fullData: { ...existingData, ...resMap },
modeTextZh: "增量更新模式",
});
}
return ({
fullData: resMap,
modeTextZh: "全覆盖模式",
});
};
const mutJsonStringify = (data) => {
if (typeof jsonMiniStyle === "boolean" && jsonMiniStyle)
return JSON.stringify(data);
return JSON.stringify(data, null, 2);
};
const {
imgNameList,
imgFullpathList,
} = getValidImgFullPathListOpt(imgPath, needHdlThisImg);
if (imgNameList.length === 0) {
console.log("🟪 静默结束,本次未有图片被处理解析");
return;
}
for (const onePath of imgFullpathList) {
ps.push(Vibrant.from(onePath).getPalette());
}
try {
const res = await Promise.all(ps);
for (const idx in res) {
const key = imgNameList[idx];
const item = res[idx];
const slotOne = hdlPaletteMutate(item);
resMap[key] = {
...item,
...slotOne,
};
}
const { fullData, modeTextZh } = getFullData(resMap);
fs.writeFileSync(outFile, mutJsonStringify(fullData));
console.log(`${modeTextZh}: 处理${imgNameList.length}张图片文件:`, ...imgNameList);
console.log("🟪 your output file:", outFile);
console.log("🟩 done.");
}
catch (e) {
console.error("❌ 异常原因中断退出", e);
}
}
/** 带副作用的函数,校验不通过即直接创建相关目录 */
function validatorOutDir$Eff(outFile) {
const outDir = path.dirname(outFile);
const cond = fs.existsSync(outDir);
let initFlag = false;
if (!cond) {
fs.mkdirSync(outDir, { recursive: true });
console.log(`🟩 已自动创建目录: \n${outDir}`);
}
if (!cond || !fs.existsSync(outFile)) {
fs.writeFileSync(outFile, JSON.stringify({}));
console.log(`🟩 已自动创建文件: \n${outFile}`);
initFlag = true;
}
return { initFlag };
}
function getAlreadyDataFromOutFile({ outFile }) {
if (!outFile) {
return ({
existingData: {},
alreadyKeysToImg: [],
});
}
try {
const fileContent = fs.readFileSync(outFile, "utf8");
const existingData = JSON.parse(fileContent);
const alreadyKeysToImg = Object.keys(existingData);
return ({
existingData,
alreadyKeysToImg,
});
}
catch (e) {
console.log("❌ 读取文件或解析发生错误!", outFile);
throw (e);
}
}
/** 一开始即过滤掉已处理过文件 */
function getValidImgFullPathListOpt(imgPath, needHdlThisImg) {
const imgNameList = [];
const imgFullpathList = [];
const filterFunc = needHdlThisImg ?? reg.test;
fs
.readdirSync(imgPath)
.forEach((it) => {
// opt: 原本的先 filter,再 forEach ,两遍遍历
if (!filterFunc(it))
return;
imgNameList.push(it);
imgFullpathList.push(path.join(imgPath, it));
});
return {
imgNameList,
imgFullpathList,
};
}
/** sideEffect mutate slotOne */
function hdlPaletteMutate(item) {
const slotOne = {
palette4Forground: {},
};
colorTypes.forEach((type) => {
if (item.palette[type]) {
const backgroundColor = item.palette[type].rgb; // [23,23,23]
slotOne.palette4Forground[type]
= shouldUseLightText(...backgroundColor) ? "#FFF" : "#000";
}
});
return slotOne;
}
基于 node-vibrant 获取图片的“调色板”信息
https://infen.cc/loc-blog/26_get-pic-palette-by-node-vibrant[Copy]转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。