剪切修改
This commit is contained in:
1 parent
1dcb27ccd9
commit
1b67933cac
5 files changed
+358
-33
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user