552 lines
20 KiB
JavaScript
552 lines
20 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const http = require('http');
|
||
const crypto = require('crypto');
|
||
const zlib = require('zlib');
|
||
const utils = require('./../js/spine-utils.js');
|
||
const render = require('./../js/spine-render.js');
|
||
|
||
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
|
||
function log(msg) {
|
||
try {
|
||
const logPath = path.join(__dirname, '..', 'thumbnails.log');
|
||
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const discoveryJobs = new Map();
|
||
const backgroundJobs = new Map();
|
||
const renderJobs = new Map();
|
||
const refreshJobs = new Map();
|
||
|
||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||
const VERSION = 'v11';
|
||
|
||
function coordinationKey(src) {
|
||
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
|
||
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
|
||
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
|
||
}
|
||
|
||
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
|
||
function assetSignature(assets) {
|
||
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
|
||
let atlasText = '';
|
||
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
|
||
const pages = utils.parseAtlasPages(atlasText);
|
||
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
|
||
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
|
||
const stats = [];
|
||
for (const file of paths) {
|
||
try {
|
||
const stat = fs.statSync(file);
|
||
if (!stat.isFile() || stat.size === 0) return null;
|
||
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
return stats.join('|');
|
||
}
|
||
|
||
/**
|
||
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
|
||
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
|
||
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
|
||
*/
|
||
async function runDiscovery(src, retries, delay) {
|
||
let assets = null;
|
||
let previousSignature = null;
|
||
let stableCount = 0;
|
||
for (let i = 0; i < retries; i++) {
|
||
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
|
||
// 分批拖入的跨批次补齐统一交给后台任务处理。
|
||
assets = utils.discoverAssets(src, {
|
||
strictStem: true,
|
||
allowCrossBatch: false,
|
||
});
|
||
const signature = assetSignature(assets);
|
||
if (signature && signature === previousSignature) stableCount += 1;
|
||
else stableCount = signature ? 1 : 0;
|
||
previousSignature = signature;
|
||
if (stableCount >= 3) return assets;
|
||
if (i < retries - 1) await sleep(delay);
|
||
}
|
||
return assets;
|
||
}
|
||
|
||
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
|
||
async function discoverWithRetry(src, retries = 5, delay = 120) {
|
||
const key = coordinationKey(src);
|
||
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
|
||
const job = runDiscovery(src, retries, delay);
|
||
discoveryJobs.set(key, job);
|
||
try {
|
||
return await job;
|
||
} finally {
|
||
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
|
||
}
|
||
}
|
||
|
||
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
|
||
async function classifyJsonBriefly(src) {
|
||
let kind = 'pending';
|
||
for (let i = 0; i < 3; i++) {
|
||
kind = utils.classifySkeletonJson(src);
|
||
if (kind !== 'pending') return kind;
|
||
if (i < 2) await sleep(80);
|
||
}
|
||
return kind;
|
||
}
|
||
|
||
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
|
||
async function renderThumbnailShared(assets, options) {
|
||
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
|
||
if (renderJobs.has(key)) {
|
||
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
|
||
return renderJobs.get(key);
|
||
}
|
||
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
|
||
const job = render.renderThumbnail(options);
|
||
renderJobs.set(key, job);
|
||
try {
|
||
return await job;
|
||
} finally {
|
||
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Eagle 格式扩展缩略图处理函数。
|
||
* 参数:{ src, dest, item }
|
||
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
|
||
* - dest : 需要生成的缩略图 PNG 路径
|
||
* - item : 当前 item 对象(可更新宽高)
|
||
*/
|
||
module.exports = async ({ src, dest, item }) => {
|
||
try {
|
||
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
|
||
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
|
||
try {
|
||
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
|
||
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
|
||
if (item && item.isDeleted) {
|
||
log(' skip: item.isDeleted=true');
|
||
return resolve(item);
|
||
}
|
||
|
||
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
|
||
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
|
||
const srcExt = utils.extOf(path.basename(src));
|
||
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
|
||
await copyImageAsThumb(src, dest);
|
||
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
|
||
return resolve(item);
|
||
}
|
||
|
||
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
|
||
// 普通 JSON 立即生成中性缩略图,不扫描资源库,也不启动后台补刷。
|
||
if (srcExt === '.json') {
|
||
const jsonKind = await classifyJsonBriefly(src);
|
||
if (jsonKind === 'other') {
|
||
log('普通 JSON,跳过 Spine 资源发现: ' + src);
|
||
await writeNeutralJsonPlaceholder(dest);
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
}
|
||
|
||
let assets = await discoverWithRetry(src);
|
||
|
||
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
|
||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
|
||
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
|
||
scheduleBackgroundReconcile(src);
|
||
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
await writePlaceholder(dest);
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
|
||
let version = utils.detectVersion(assets.skeletonPath);
|
||
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||
|
||
if (!runtimeDir) {
|
||
if (await writeAtlasFallback(assets, dest)) {
|
||
await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
await writePlaceholder(dest);
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
|
||
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
|
||
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
|
||
let png = null;
|
||
let lastRenderError = null;
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
png = await renderThumbnailShared(assets, {
|
||
skeletonPath: assets.skeletonPath,
|
||
atlasPath: assets.atlasPath,
|
||
texturePaths: assets.texturePaths,
|
||
version,
|
||
runtimeDir,
|
||
size: 400,
|
||
});
|
||
break;
|
||
} catch (e) {
|
||
lastRenderError = e;
|
||
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
|
||
if (attempt === 2) break;
|
||
await sleep(500);
|
||
assets = await discoverWithRetry(src, 4, 150);
|
||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
|
||
version = utils.detectVersion(assets.skeletonPath);
|
||
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||
}
|
||
}
|
||
if (!png) {
|
||
if (await writeAtlasFallback(assets, dest)) {
|
||
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
|
||
await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
throw lastRenderError || new Error('Spine 资源尚未写入完成');
|
||
}
|
||
|
||
fs.writeFileSync(dest, png);
|
||
await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||
|
||
try {
|
||
const size = imageSizeOf(dest);
|
||
item.width = size.width || 400;
|
||
item.height = size.height || 400;
|
||
} catch (e) { /* ignore */ }
|
||
|
||
return Promise.resolve(item);
|
||
} catch (err) {
|
||
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
|
||
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
|
||
try {
|
||
await writePlaceholder(dest);
|
||
item.width = 400;
|
||
item.height = 400;
|
||
return resolve(item);
|
||
} catch (e2) {
|
||
// 最后兜底:写入一张最小 1x1 PNG
|
||
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
|
||
item.width = 400; item.height = 400;
|
||
return resolve(item);
|
||
}
|
||
}
|
||
};
|
||
|
||
function resolve(item) {
|
||
return item;
|
||
}
|
||
|
||
function itemIdFromAssetPath(filePath) {
|
||
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
function setEagleCustomThumbnail(itemId, thumbnailPath) {
|
||
return new Promise((resolveRequest, rejectRequest) => {
|
||
const body = JSON.stringify({ id: itemId, thumbnailPath });
|
||
const request = http.request({
|
||
hostname: '127.0.0.1',
|
||
port: 41595,
|
||
path: '/api/item/setCustomThumbnail',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||
}, (response) => {
|
||
let data = '';
|
||
response.on('data', (chunk) => { data += chunk; });
|
||
response.on('end', () => {
|
||
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
|
||
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
|
||
});
|
||
});
|
||
request.on('error', rejectRequest);
|
||
request.end(body);
|
||
});
|
||
}
|
||
|
||
async function createWebpCopy(sourcePath, key) {
|
||
if (!render.canvasLib) return sourcePath;
|
||
const image = await render.canvasLib.loadImage(sourcePath);
|
||
const canvas = render.canvasLib.createCanvas(image.width, image.height);
|
||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||
const unique = crypto.randomBytes(8).toString('hex');
|
||
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
|
||
fs.writeFileSync(output, await canvas.encode('webp', 90));
|
||
return output;
|
||
}
|
||
|
||
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
|
||
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
|
||
const signature = assetSignature(assets);
|
||
if (!signature) return;
|
||
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
|
||
if (ids.length === 0) return;
|
||
const key = signature + '|' + ids.slice().sort().join(',');
|
||
if (refreshJobs.has(key)) return refreshJobs.get(key);
|
||
const job = (async () => {
|
||
let apiPath = thumbnailPath;
|
||
try {
|
||
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
|
||
const results = await Promise.allSettled(ids.map((id) => setEagleCustomThumbnail(id, apiPath)));
|
||
const failed = results.filter((result) => result.status === 'rejected');
|
||
if (failed.length) throw failed[0].reason;
|
||
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
|
||
} catch (e) {
|
||
log('统一补刷失败: ' + ((e && e.message) || String(e)));
|
||
} finally {
|
||
if (apiPath !== thumbnailPath) {
|
||
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
})();
|
||
refreshJobs.set(key, job);
|
||
try {
|
||
await job;
|
||
} finally {
|
||
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
|
||
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
|
||
*/
|
||
function scheduleBackgroundReconcile(src) {
|
||
const key = coordinationKey(src);
|
||
if (backgroundJobs.has(key)) return;
|
||
const job = (async () => {
|
||
let previousSignature = null;
|
||
let stableCount = 0;
|
||
// 前几秒快速观察,随后降低频率;总计约两分钟。若更晚补拖第三个文件,
|
||
// 新文件自己的缩略图回调会再次触发同一资源链发现,无需常驻轮询十分钟。
|
||
for (let i = 0; i < 36; i++) {
|
||
const delay = i < 8 ? 1000 : (i < 20 ? 2500 : 5000);
|
||
await sleep(delay);
|
||
const assets = utils.discoverAssets(src, {
|
||
strictStem: true,
|
||
allowCrossBatch: true,
|
||
});
|
||
const signature = assetSignature(assets);
|
||
if (signature && signature === previousSignature) stableCount += 1;
|
||
else stableCount = signature ? 1 : 0;
|
||
previousSignature = signature;
|
||
if (stableCount < 3) continue;
|
||
|
||
const tempPng = path.join(os.tmpdir(), 'spineviewer-reconcile-' + process.pid + '-' + Date.now() + '.png');
|
||
try {
|
||
const version = utils.detectVersion(assets.skeletonPath);
|
||
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||
let wrote = false;
|
||
if (runtimeDir) {
|
||
try {
|
||
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
|
||
fs.writeFileSync(tempPng, png);
|
||
wrote = true;
|
||
} catch (e) {
|
||
wrote = await writeAtlasFallback(assets, tempPng);
|
||
}
|
||
}
|
||
if (wrote) {
|
||
await refreshRelatedThumbnails(assets, tempPng);
|
||
log('后台识别到完整资源链并补刷: ' + key);
|
||
}
|
||
} finally {
|
||
try { if (fs.existsSync(tempPng)) fs.unlinkSync(tempPng); } catch (e) { /* ignore */ }
|
||
}
|
||
return;
|
||
}
|
||
log('后台等待资源链超时: ' + key);
|
||
})();
|
||
backgroundJobs.set(key, job);
|
||
job.catch((e) => log('后台补刷异常: ' + ((e && e.message) || String(e))))
|
||
.finally(() => { if (backgroundJobs.get(key) === job) backgroundJobs.delete(key); });
|
||
}
|
||
|
||
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
|
||
async function writeNeutralJsonPlaceholder(dest) {
|
||
const size = 400;
|
||
if (render.canvasLib) {
|
||
try {
|
||
const canvas = render.canvasLib.createCanvas(size, size);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#202126';
|
||
ctx.fillRect(0, 0, size, size);
|
||
ctx.fillStyle = '#555963';
|
||
ctx.beginPath();
|
||
ctx.moveTo(112, 72);
|
||
ctx.lineTo(244, 72);
|
||
ctx.lineTo(288, 116);
|
||
ctx.lineTo(288, 328);
|
||
ctx.lineTo(112, 328);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = '#777c87';
|
||
ctx.beginPath();
|
||
ctx.moveTo(244, 72);
|
||
ctx.lineTo(244, 116);
|
||
ctx.lineTo(288, 116);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||
return;
|
||
} catch (e) { /* fall through */ }
|
||
}
|
||
const pixels = Buffer.alloc(size * size * 4);
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
|
||
}
|
||
fs.writeFileSync(dest, encodePng(size, size, pixels));
|
||
}
|
||
|
||
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
|
||
async function copyImageAsThumb(src, dest) {
|
||
const ext = path.extname(src).toLowerCase();
|
||
if (ext === '.png') {
|
||
fs.copyFileSync(src, dest);
|
||
return;
|
||
}
|
||
if (render.canvasLib) {
|
||
try {
|
||
const img = await render.canvasLib.loadImage(src);
|
||
const w = img.width, h = img.height;
|
||
const canvas = render.canvasLib.createCanvas(w, h);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||
return;
|
||
} catch (e) { /* fall through */ }
|
||
}
|
||
fs.copyFileSync(src, dest);
|
||
}
|
||
|
||
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
|
||
async function writeAtlasFallback(assets, dest) {
|
||
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
|
||
const texturePath = Object.values(assets.texturePaths).find((file) => {
|
||
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
|
||
});
|
||
if (!texturePath) return false;
|
||
try {
|
||
const img = await render.canvasLib.loadImage(texturePath);
|
||
const size = 400;
|
||
const canvas = render.canvasLib.createCanvas(size, size);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#1e1e22';
|
||
ctx.fillRect(0, 0, size, size);
|
||
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
|
||
const w = Math.max(1, Math.round(img.width * scale));
|
||
const h = Math.max(1, Math.round(img.height * scale));
|
||
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
|
||
await render.drawSpineBadge(ctx, size, size);
|
||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||
return true;
|
||
} catch (e) {
|
||
log('atlas 贴图回退失败: ' + (e && e.message));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 读取 PNG 宽高(仅支持 PNG 头部) */
|
||
function imageSizeOf(file) {
|
||
const buf = fs.readFileSync(file);
|
||
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
|
||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||
}
|
||
return { width: 0, height: 0 };
|
||
}
|
||
|
||
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
|
||
async function writePlaceholder(dest) {
|
||
const w = 400, h = 400;
|
||
if (render.canvasLib) {
|
||
try {
|
||
const canvas = render.canvasLib.createCanvas(w, h);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#1e1e22';
|
||
ctx.fillRect(0, 0, w, h);
|
||
await render.drawSpineBadge(ctx, w, h);
|
||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||
return;
|
||
} catch (e) { /* fall through to pure JS encoder */ }
|
||
}
|
||
// 深色底
|
||
const pixels = Buffer.alloc(w * h * 4);
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const i = (y * w + x) * 4;
|
||
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
|
||
}
|
||
}
|
||
// 简单的斜线纹理,让占位图不单调
|
||
for (let i = 0; i < w + h; i += 8) {
|
||
for (let k = 0; k < 3; k++) {
|
||
const x = Math.min(w - 1, i - k);
|
||
const y = Math.min(h - 1, k);
|
||
const idx = (y * w + x) * 4;
|
||
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
|
||
}
|
||
}
|
||
fs.writeFileSync(dest, encodePng(w, h, pixels));
|
||
}
|
||
|
||
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
|
||
function encodePng(width, height, rgba) {
|
||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||
const ihdr = Buffer.alloc(13);
|
||
ihdr.writeUInt32BE(width, 0);
|
||
ihdr.writeUInt32BE(height, 4);
|
||
ihdr[8] = 8; // bit depth
|
||
ihdr[9] = 6; // color type RGBA
|
||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||
const ihdrChunk = chunk('IHDR', ihdr);
|
||
|
||
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||
for (let y = 0; y < height; y++) {
|
||
raw[y * (width * 4 + 1)] = 0; // filter type 0
|
||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||
}
|
||
const idat = zlib.deflateSync(raw);
|
||
const idatChunk = chunk('IDAT', idat);
|
||
const iend = chunk('IEND', Buffer.alloc(0));
|
||
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
|
||
}
|
||
|
||
function chunk(type, data) {
|
||
const len = Buffer.alloc(4);
|
||
len.writeUInt32BE(data.length, 0);
|
||
const typeBuf = Buffer.from(type, 'ascii');
|
||
const crcBuf = Buffer.alloc(4);
|
||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||
}
|
||
|
||
function crc32(buf) {
|
||
let c, crc = 0xFFFFFFFF;
|
||
for (let i = 0; i < buf.length; i++) {
|
||
c = (crc ^ buf[i]) & 0xFF;
|
||
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||
crc = (crc >>> 8) ^ c;
|
||
}
|
||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||
}
|