剪切修改

This commit is contained in:
tianmo committed 2026-09-09 15:58:56 +08:00
1 parent 1dcb27ccd9
commit 1b67933cac
5 files changed
+358 -33

No files matched your search

+3 -1
View File
@@ -56,13 +56,15 @@ SpineViewer/
- 播放 / 暂停(空格)
- 循环开关
- 播放速度调节
- 预乘 Alpha(PMA)贴图渲染开关
- 时间轴拖动、动画时长、秒刻度与关键帧标记
- 零时长动画按单帧静态姿势预览
- 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,便于识别数据文件。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。
- 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。
- 暂时缺少骨骼、atlas 或贴图时先显示回退图,并在后台继续观察约 10 分钟;链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新。
- JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
---
+198
View File
@@ -0,0 +1,198 @@
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!doc || !image.width || !image.height) return image;
const canvas = doc.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7]);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, pma);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, pma);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const image = imageForCanvas(imageFromAttachment(attachment), context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles);
} else {
drawInterleaved(base, image, vertices, triangles);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
}
};
base.clipper = clipper;
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, pma); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1]);
}
}
} finally {
context.globalAlpha = 1;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
return runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
}
module.exports = { createRenderer };
+63 -18
View File
@@ -16,6 +16,8 @@ const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉
const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i;
@@ -117,21 +119,53 @@ function chooseRuntimeDir(version, isBinary) {
}
/**
* 判断给定的 JSON 文件是否为 Spine 骨骼 JSON,避免把普通 JSON 误判为骨骼数据。
* 判定条件:存在 `skeleton` 及其 `spine` 版本字段,或同时存在 `bones` + `slots` 数组。
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
*/
function looksLikeSkeletonJson(filePath) {
function classifySkeletonJson(filePath) {
try {
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!json || typeof json !== 'object') return false;
if (json.skeleton && json.skeleton.spine) return true;
if (Array.isArray(json.bones) && json.bones.length > 0 && Array.isArray(json.slots)) return true;
return false;
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return 'pending';
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
const cached = skeletonJsonCache.get(filePath);
if (cached && cached.signature === signature) return cached.kind;
const length = Math.min(stat.size, JSON_PROBE_BYTES);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(length);
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
const text = buffer.toString('utf8');
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
const hasSkeletonVersion = /"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:/i.test(text);
let kind;
if (hasSkeletonVersion) {
kind = 'spine';
} else if (stat.size > JSON_PROBE_BYTES) {
kind = 'other';
} else {
try {
JSON.parse(text);
kind = 'other';
} catch (e) {
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
kind = hasSkeletonKey ? 'pending' : 'other';
}
}
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
skeletonJsonCache.set(filePath, { signature, kind });
return kind;
} catch (e) {
return false;
return 'pending';
}
}
function looksLikeSkeletonJson(filePath) {
return classifySkeletonJson(filePath) === 'spine';
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
@@ -330,7 +364,7 @@ function collectSpineFilesCached(filePath, roots) {
const key = libRoot || path.dirname(path.dirname(filePath));
const now = Date.now();
const cached = fileIndexCache.get(key);
if (cached && now - cached.time < 180) return cached.files;
if (cached && now - cached.time < 1500) return cached.files;
const files = collectSpineFiles(roots, null);
fileIndexCache.set(key, { time: now, files });
return files;
@@ -379,7 +413,8 @@ function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const preferCompatibleJson = !!options.preferCompatibleJson;
const allowCrossBatch = !!options.allowCrossBatch;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
@@ -392,6 +427,10 @@ function discoverAssets(filePath, options) {
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
let files = collectSpineFiles(roots, validFolders);
if (strictStem) {
const allFiles = collectSpineFilesCached(filePath, roots);
@@ -402,9 +441,14 @@ function discoverAssets(filePath, options) {
}
}
const skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
// 避免每个回调都解析库中所有普通 JSON。
if (strictStem) {
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
}
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
@@ -486,7 +530,7 @@ function discoverAssets(filePath, options) {
f.name.toLowerCase().endsWith('.json') &&
normalizeStem(f.name) === stem &&
familyOfSkel(f) === familyOfSkel(skeleton) &&
(!strictStem || allowCrossBatch || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000)
(!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= allowedImportDelta)
);
const jsonFallback = selectBest(jsonCands, skeleton.fullPath);
if (jsonFallback) skeleton = jsonFallback;
@@ -532,9 +576,9 @@ function discoverAssets(filePath, options) {
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && !allowCrossBatch && skeleton && atlas) {
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > 30000) {
if (pairDelta > allowedImportDelta) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
@@ -558,7 +602,7 @@ function discoverAssets(filePath, options) {
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn));
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && !allowCrossBatch && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null;
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
}
}
@@ -572,12 +616,12 @@ function discoverAssets(filePath, options) {
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfSkel(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= 30000
Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedAtlasPaths = atlas ? atlasFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfAtlas(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - atlasTime) <= 30000
Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
@@ -631,6 +675,7 @@ module.exports = {
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
classifySkeletonJson,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
+71 -9
View File
@@ -22,7 +22,7 @@ const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v7';
const VERSION = 'v10';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -61,24 +61,25 @@ async function runDiscovery(src, retries, delay) {
let previousSignature = null;
let stableCount = 0;
for (let i = 0; i < retries; i++) {
// 前 5 秒只接受同批资源;之后允许在同一个 Eagle 文件夹内补齐分批拖入的链路。
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
preferCompatibleJson: true,
allowCrossBatch: i >= Math.min(20, retries - 1),
allowCrossBatch: false,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount >= 3) return assets;
await sleep(delay);
if (i < retries - 1) await sleep(delay);
}
return assets;
}
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
async function discoverWithRetry(src, retries = 48, delay = 250) {
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);
@@ -90,6 +91,17 @@ async function discoverWithRetry(src, retries = 48, delay = 250) {
}
}
/** 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;
@@ -138,6 +150,18 @@ module.exports = async ({ src, dest, item }) => {
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 引用的全部贴图)
@@ -198,7 +222,7 @@ module.exports = async ({ src, dest, item }) => {
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
if (attempt === 2) break;
await sleep(500);
assets = await discoverWithRetry(src, 16, 250);
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);
@@ -325,9 +349,11 @@ function scheduleBackgroundReconcile(src) {
const job = (async () => {
let previousSignature = null;
let stableCount = 0;
// 最长观察约 10 分钟,覆盖大型文件夹分批落盘;每个资源组只有一个任务。
for (let i = 0; i < 300; i++) {
await sleep(2000);
// 前几秒快速观察,随后降低频率;总计约两分钟。若更晚补拖第三个文件,
// 新文件自己的缩略图回调会再次触发同一资源链发现,无需常驻轮询十分钟。
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,
preferCompatibleJson: true,
@@ -371,6 +397,42 @@ function scheduleBackgroundReconcile(src) {
.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();
+23 -5
View File
@@ -136,6 +136,9 @@
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
<span id="speedVal">1.0x</span>
</label>
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck"> 预乘
</label>
<div class="spacer"></div>
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
@@ -172,6 +175,7 @@
const fs = require('fs');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -232,8 +236,10 @@
// ---------- 全局状态 ----------
let skeleton = null, state = null, stateData = null, skeletonData = null;
let renderer = null;
let activeRuntimeDir = null;
let currentAnim = 0, currentSkin = null;
let playing = true, loop = true, speed = 1;
let premultipliedAlpha = false;
let timeKeeper = null, lastTime = 0, running = false;
let seeking = false; // 用户正在拖动时间轴
let duration = 0; // 当前动画时长
@@ -450,9 +456,9 @@
octx.translate(-cx, -cy);
let offRenderer = renderer;
try {
if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx);
else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx);
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
} catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ }
offRenderer.premultipliedAlpha = premultipliedAlpha;
if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true;
offRenderer.draw(skeleton);
octx.restore();
@@ -789,6 +795,12 @@
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
// Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容,
// 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。
if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') {
showError('普通 JSON 文件', '该文件不包含 Spine 导出的 skeleton.spine 版本字段,因此不会作为 Spine 骨骼数据加载。');
return;
}
const assets = utils.discoverAssets(filePath);
if (!assets.skeletonPath || !assets.atlasPath) {
@@ -807,6 +819,7 @@
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
return;
}
activeRuntimeDir = runtimeDir;
document.getElementById('verLabel').textContent = 'Spine ' + (version || runtimeDir);
try {
@@ -864,9 +877,8 @@
stateData.defaultMix = 0.2;
state = new spine.AnimationState(stateData);
renderer = (spine.canvas && spine.canvas.SkeletonRenderer) ? new spine.canvas.SkeletonRenderer(ctx) : new spine.SkeletonRenderer(ctx);
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if (renderer && 'triangleRendering' in renderer) renderer.triangleRendering = true;
renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir);
renderer.premultipliedAlpha = premultipliedAlpha;
fillAnimations();
fillSkins();
@@ -931,6 +943,12 @@
speed = parseFloat(e.target.value);
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
});
document.getElementById('pmaCheck').addEventListener('change', (e) => {
premultipliedAlpha = e.target.checked;
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
draw();
setStatus(premultipliedAlpha ? '已开启预乘 Alpha 渲染' : '已关闭预乘 Alpha 渲染', 1800);
});
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});