加入3.5 修复水印

This commit is contained in:
tianmo committed 2026-09-10 18:51:29 +08:00
1 parent ef01cdd6ee
commit e82a62a9ad
19 files changed
+5894 -38

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
'use strict';
const fs=require('fs'),path=require('path'),os=require('os');
const root=path.resolve(__dirname,'..'),fixtures=path.join(__dirname,'.fixtures');
const script=text=>'<script>'+text.replace(/<\/script/gi,'<\\/script')+'</script>';
const versions=['3.4','3.5','3.6','3.7','3.8','4.0','4.1','4.2','4.3'];
const frames=versions.map(version=>{
const dir=path.join(fixtures,version,'spineboy');
const source=path.join(dir,['3.4','3.5'].includes(version)?'spineboy-mesh.skel':'spineboy-pro.skel');
let html=script(`var module={exports:{}};const modules={};function require(n){return modules[n]}const version=${JSON.stringify(version)};`);
for(const name of ['spine-webgl-rasterizer','spine-canvas-clipping','spine-background','spine-legacy-binary'])
html+=script('module={exports:{}};\n'+fs.readFileSync(path.join(root,'js',name+'.js'),'utf8')+`\nmodules['./${name}.js']=module.exports;`);
html+=script(fs.readFileSync(path.join(root,'lib',version,'spine-canvas.js'),'utf8'));
html+=script(`(async()=>{try{
const image=new Image();image.src='data:image/png;base64,${fs.readFileSync(path.join(dir,'spineboy.png')).toString('base64')}';await image.decode();
const text=${JSON.stringify(fs.readFileSync(path.join(dir,'spineboy.atlas'),'utf8'))};
const texture=new (spine.CanvasTexture||spine.canvas.CanvasTexture)(image);let atlas;
if(spine.TextureAtlas.length<=1){atlas=new spine.TextureAtlas(text);for(const page of atlas.pages){if(page.setTexture)page.setTexture(texture);else page.texture=texture}}
else atlas=new spine.TextureAtlas(text,()=>texture);
modules['./spine-legacy-binary.js'].install(spine,version);
const bytes=Uint8Array.from(atob('${fs.readFileSync(source).toString('base64')}'),c=>c.charCodeAt(0));
const data=new spine.SkeletonBinary(new spine.AtlasAttachmentLoader(atlas)).readSkeletonData(bytes),skeleton=new spine.Skeleton(data);
if(spine.Physics)skeleton.updateWorldTransform(spine.Physics.update);else skeleton.updateWorldTransform();
const offset=new spine.Vector2(),size=new spine.Vector2();skeleton.getBounds(offset,size,[]);
const canvas=document.createElement('canvas');canvas.width=canvas.height=400;document.body.appendChild(canvas);
const ctx=canvas.getContext('2d'),renderer=modules['./spine-canvas-clipping.js'].createRenderer(spine,ctx,version);
if(renderer.rasterization!=='webgl')throw Error('WebGL not active');
let normal;
for(const mode of [0,2]){
ctx.setTransform(1,0,0,1,0,0);ctx.fillStyle='#1e1e1e';ctx.fillRect(0,0,400,400);
const scale=320/Math.max(size.x,size.y);ctx.translate(200,200);ctx.scale(scale,-scale);ctx.translate(-offset.x-size.x/2,-offset.y-size.y/2);
modules['./spine-background.js'].drawCheckerboard(ctx,400,400);
for(const slot of skeleton.slots)slot.data.blendMode=mode;
renderer.draw(skeleton);const pixels=ctx.getImageData(0,0,400,400).data;
if(mode===0)normal=pixels;else{let changed=0;for(let i=0;i<pixels.length;i+=4)if(Math.abs(pixels[i]-normal[i])>3)changed++;if(changed<100)throw Error('Slot multiply did not change image');}
}
renderer.premultipliedAlpha=true;renderer.draw(skeleton);renderer.dispose();
parent.postMessage({version,result:'PASS'},'*');
}catch(error){parent.postMessage({version,error:error.stack},'*')}})();`);
return html;
});
const html='<!doctype html><meta charset="utf-8"><style>iframe{width:420px;height:420px;border:0}body{background:#222;color:#eee}</style><pre id="result">RUNNING</pre>'+script(`let results=[];window.addEventListener('message',e=>{if(e.data.version){results.push(e.data);document.getElementById('result').textContent=JSON.stringify(results)}});for(const html of ${JSON.stringify(frames)}){const frame=document.createElement('iframe');frame.srcdoc=html;document.body.appendChild(frame)}`);
const output=path.resolve(process.argv[2]||path.join(os.tmpdir(),'spineviewer-browser-preview-test.html'));
fs.writeFileSync(output,html);console.log(output);
+1 -1
View File
@@ -15,7 +15,7 @@ for (const name of ['notes.txt', 'payload.bytes', 'hero.atlas.txt.bak']) assert.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-extensions-test-'));
try {
// Sibling directories with identical filenames must not share stale indexes.
for (const version of ['3.4', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
for (const version of ['3.4', '3.5', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
const directory = path.join(root, version); fs.mkdirSync(directory);
const skeleton = path.join(directory, 'hero.skel.bytes');
const atlas = path.join(directory, 'hero.atlas.txt');
+5 -4
View File
@@ -4,17 +4,18 @@ const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
if (process.argv[3] === '3.4') {
const revision = 'ef5013143178918ddfc7cabfbddeae7aec7cf84e';
if (['3.4', '3.5'].includes(process.argv[3])) {
const version = process.argv[3];
const revision = version === '3.4' ? 'ef5013143178918ddfc7cabfbddeae7aec7cf84e' : '2cd9467d2736b7f3a0b81382f117c73823bfb44b';
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'raptor']) {
const directory = path.join(destination, '3.4', name);
const directory = path.join(destination, version, name);
fs.mkdirSync(directory, { recursive: true });
const variants = name === 'spineboy' ? [name, name + '-mesh', name + '-hover'] : name === 'goblins' ? [name, name + '-mesh'] : [name];
for (const file of [...variants.flatMap(v => [v + '.json', v + '.skel']), name + '.atlas', name + '.png']) {
const url = `https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/examples/${name}/export/${file}`;
execFileSync('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
}
console.log('3.4 / ' + name);
console.log(version + ' / ' + name);
}
process.exit(0);
}
+76
View File
@@ -0,0 +1,76 @@
'use strict';
// Version-pinned, complete official export sets for manual Eagle testing.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const root = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
const revisions = {
'3.4': 'ef5013143178918ddfc7cabfbddeae7aec7cf84e',
'3.5': '2cd9467d2736b7f3a0b81382f117c73823bfb44b',
'3.6': '654c20e5b0e523040b6366bbd1042510d2645134',
'3.7': '9639bcc81722d7178fd9d1cdc1a3d55a4c91f989',
'3.8': '8b4844bd4b193ba9e54487ed397a777993cbad56',
'4.0': '425ce416bb218b28caeec47b317aa57cd7140375',
'4.1': '77a5db0ec6d16331f5efbaa7662bba9355bd3424',
'4.2': 'e7dc1435fa4a0083ab431f1b28e083c14a1f5c68',
'4.3': 'ba17cf88b198c6531c6c51d8d4e1e4b3c3ab47dd'
};
const names = ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor'];
const curl = (url, file) => new Promise((resolve, reject) => execFile('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', file], error => error ? reject(error) : resolve()));
function blobHash(file) {
const bytes = fs.readFileSync(file);
return crypto.createHash('sha1').update('blob ' + bytes.length + '\0').update(bytes).digest('hex');
}
async function pool(jobs) {
let cursor = 0;
await Promise.all(Array.from({ length: 6 }, async () => { while (cursor < jobs.length) await jobs[cursor++](); }));
}
(async () => {
fs.mkdirSync(root, { recursive: true });
const inventory = [];
for (const [version, revision] of Object.entries(revisions)) {
const folder = path.join(root, version); fs.mkdirSync(folder, { recursive: true });
let previous = [];
try { previous = JSON.parse(fs.readFileSync(path.join(folder, '.sources.json'), 'utf8')).files; } catch (_) {}
const treeFile = path.join(folder, '.tree.json');
await curl(`https://api.github.com/repos/EsotericSoftware/spine-runtimes/git/trees/${revision}?recursive=1`, treeFile);
const tree = JSON.parse(fs.readFileSync(treeFile, 'utf8'));
if (tree.truncated || !tree.tree || tree.sha !== revision) throw new Error('Incomplete source tree: ' + version);
const license = tree.tree.find(f => f.path === 'LICENSE');
if (license) {
const dest = path.join(folder, 'LICENSE.txt');
if (!fs.existsSync(dest) || blobHash(dest) !== license.sha) await curl(`https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/LICENSE`, dest);
if (blobHash(dest) !== license.sha) throw new Error('License checksum mismatch: ' + version);
}
const files = tree.tree.filter(f => f.type === 'blob' && names.some(n => f.path.startsWith('examples/' + n + '/export/')) && /\.(json|skel|atlas|png)$/.test(f.path) && !/-pma/.test(f.path));
for (const old of previous.filter(old => !files.some(f => f.path === old.path))) {
const parts = old.path.split('/'), dest = path.join(folder, parts[1], parts.slice(3).join('/'));
if (fs.existsSync(dest) && blobHash(dest) === old.sha) {
fs.unlinkSync(dest);
if (!fs.readdirSync(path.dirname(dest)).length) fs.rmdirSync(path.dirname(dest));
}
}
await pool(files.map(f => async () => {
const parts = f.path.split('/'), dest = path.join(folder, parts[1], parts.slice(3).join('/'));
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (fs.existsSync(dest) && blobHash(dest) === f.sha) return;
const temp = dest + '.download';
await curl(`https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/${f.path}`, temp);
if (blobHash(temp) !== f.sha) throw new Error('Checksum mismatch: ' + f.path);
fs.renameSync(temp, dest);
}));
for (const f of files.filter(f => f.path.endsWith('.json'))) {
const parts = f.path.split('/');
const data = JSON.parse(fs.readFileSync(path.join(folder, parts[1], parts.slice(3).join('/')), 'utf8'));
if (!data.skeleton || !data.skeleton.spine.startsWith(version + '.')) throw new Error('Wrong export version: ' + f.path);
}
const entry = { version, revision, files: files.map(f => ({ path: f.path, sha: f.sha, size: f.size })) };
fs.writeFileSync(path.join(folder, '.sources.json'), JSON.stringify(entry, null, 2));
inventory.push(entry);
console.log(version + ': ' + files.length + ' official files verified');
}
const rows = inventory.map(e => '| ' + e.version + ' | ' + new Set(e.files.map(f => f.path.split('/')[1])).size + ' | ' + e.files.length + ' | `' + e.revision + '` |').join('\n');
fs.writeFileSync(path.join(root, 'README.md'), '# Spine 官方测试资源\n\n按版本和示例分目录,骨骼、Atlas 和贴图均来自官方仓库,所有文件已校验 Git blob SHA-1。这里下载的是普通 Alpha 导出,未混入同名 PMA 图集。\n\n| 版本系列 | 示例组 | 文件数 | 固定提交 |\n|---|---:|---:|---|\n' + rows + '\n\n将单个示例文件夹导入 Eagle。JSON 与 SKEL 可分别测试;测试复合后缀时请复制文件后将 `.atlas` 改为 `.atlas.txt`、`.skel` 改为 `.skel.bytes`。保留原始版本作为对照。每个版本的 `.sources.json` 记录来源文件及校验值。\n\n官方仓库:https://github.com/EsotericSoftware/spine-runtimes\n');
console.log('Saved to ' + root);
})().catch(error => { console.error(error); process.exitCode = 1; });
+14 -10
View File
@@ -24,30 +24,34 @@ function runtime(version) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
return install(context.spine, version);
}
const versions = process.argv[3] === '3.4' ? ['3.4'] : ['3.6', '3.7'];
const versions = ['3.4', '3.5'].includes(process.argv[3]) ? [process.argv[3]] : ['3.6', '3.7'];
let pairs = 0;
for (const version of versions) {
const spine = runtime(version);
const oldNames = version === '3.4' || version === '3.5';
// 3.4 JSON export truncates to two decimal places instead of rounding.
const quantization = version === '3.4' ? 0.0102 : 0.0052;
const quantization = oldNames ? 0.0102 : 0.0052;
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
for (const name of version === '3.4' ? ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'] : names) {
const directory = path.join(fixtures, version, version === '3.4' ? name.split('-')[0] : name);
const file = name + (version === '3.4' ? '' : '-pro');
const oldExamples = ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'];
// The 3.5.51 tag keeps beta SKELs but newer 3.5.49 JSON for Tank/Raptor.
// Those independent exports are smoke-tested separately, not used as parity oracles.
for (const name of oldNames ? oldExamples.filter(n => version !== '3.5' || !['tank', 'raptor'].includes(n)) : names) {
const directory = path.join(fixtures, version, oldNames ? name.split('-')[0] : name);
const file = name + (oldNames ? '' : '-pro');
const bytes = fs.readFileSync(path.join(directory, file + '.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, file + '.json'), 'utf8'));
const decoded = decode(bytes, version);
assert.equal(decoded.skeleton.hash, json.skeleton.hash);
assert.equal(decoded.skeleton.spine, json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, file + '.skel')), json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, file + '.skel')), json.skeleton.spine.match(/^\d+\.\d+\.\d+/)[0]);
// Region geometry uses a real official atlas; no native Canvas dependency
// is needed to verify animation poses and renderer triangle output.
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (version === '3.4' ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture);
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (oldNames ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture);
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
// 3.4 Goblins binary optimizes three constant segments to linear;
// JSON keeps stepped. Their equal endpoints produce identical poses.
if (version === '3.4' && name.startsWith('goblins')) {
if (oldNames && name.startsWith('goblins')) {
for (const [bone, type, index] of [['left lower leg', 'translate', 2], ['right lower leg', 'translate', 0], ['hip', 'rotate', 0]]) {
const timeline = json.animations.walk.bones[bone][type];
assert.equal(timeline[index].curve, 'stepped');
@@ -92,7 +96,7 @@ for (const version of versions) {
}
for (const animation of data.animations) {
const refAnimation = expected.findAnimation(animation.name);
near(animation.duration, refAnimation.duration, version === '3.4' ? 0.00011 : 0.00006, 'duration');
near(animation.duration, refAnimation.duration, oldNames ? 0.00011 : 0.00006, 'duration');
assert.equal(animation.timelines.length, refAnimation.timelines.length);
// Older JSON and binary exports order independent slot timelines differently.
const timelineKey = t => [t.constructor.name, t.slotIndex, t.boneIndex, t.ikConstraintIndex, t.transformConstraintIndex, t.pathConstraintIndex, t.attachment && t.attachment.name].join('/');
@@ -122,7 +126,7 @@ for (const version of versions) {
// Quantized JSON weights/curves compound along bone chains.
// 3.4 truncates weights/scales by up to 0.01. Bound accumulated
// world-position error by 1% of export size; local data is checked above.
const tolerance = version === '3.4' && field.startsWith('world')
const tolerance = oldNames && field.startsWith('world')
? Math.max(2.1, Math.max(json.skeleton.width, json.skeleton.height) * scale * 0.01) : 2.1;
near(pair[0].bones[i][field], pair[1].bones[i][field], tolerance, name + '/' + animation.name + '/' + field);
}
+34
View File
@@ -0,0 +1,34 @@
'use strict';
const fs=require('fs'),path=require('path'),assert=require('assert/strict');
const render=require('../js/spine-render'),utils=require('../js/spine-utils'),clipping=require('../js/spine-canvas-clipping');
const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures'));
(async()=>{
let files=0,poses=0;
for(const version of ['3.4','3.5','3.6','3.7','3.8','4.0','4.1','4.2','4.3']){
const spine=render.loadRuntime(version);
for(const name of fs.readdirSync(path.join(root,version)).filter(n=>!n.startsWith('.')&&fs.statSync(path.join(root,version,n)).isDirectory())){
const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas');
if(!fs.existsSync(atlasPath))continue;
const text=fs.readFileSync(atlasPath,'utf8');
for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page);
const atlas=await render.buildTextureAtlas(spine,text,dir);
for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){
const source=path.join(dir,file),detected=utils.detectVersion(source);
assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version');
const data=render.readSkeletonData(spine,source,atlas,file.endsWith('.skel'));
assert(data.bones.length>0,source);
const renderer=clipping.createRenderer(spine,{globalAlpha:1,globalCompositeOperation:'source-over'},version);
let triangles=0;renderer.drawTriangle=(_image,...coords)=>{assert(coords.every(Number.isFinite),source+' geometry');triangles++};
for(const animation of data.animations){
const skeleton=new spine.Skeleton(data),state=new spine.AnimationState(new spine.AnimationStateData(data));
state.setAnimation(0,animation.name,true);state.update(animation.duration*0.37);state.apply(skeleton);
if(spine.Physics)skeleton.updateWorldTransform(spine.Physics.update);else skeleton.updateWorldTransform();
renderer.draw(skeleton);poses++;
}
assert(triangles>0||data.animations.length===0,source+' empty geometry');files++;
}
}
console.log('PASS '+version+' official exports');
}
console.log('PASS: '+files+' skeleton files, '+poses+' animation/render checks; all atlas pages present');
})().catch(error=>{console.error(error);process.exitCode=1});
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><meta charset="utf-8"><title>Spine preview compositing tests</title>
<pre id="result">RUNNING</pre>
<script>var module={exports:{}},__dirname='';function require(n){return n==='path'?{join:()=>''}:{}}</script>
<script src="../js/spine-webgl-rasterizer.js"></script>
<script>const rasterizer=module.exports;module={exports:{}};</script>
<script src="../js/spine-background.js"></script>
<script>const background=module.exports;module={exports:{}};</script>
<script src="../js/spine-badge.js"></script>
<script>
try {
const badge=module.exports;
function check(ok,message){if(!ok)throw Error(message)}
function canvas(w,h){const c=document.createElement('canvas');c.width=w;c.height=h;return c.getContext('2d')}
function pixel(c,x,y){return c.getImageData(x,y,1,1).data}
const grid=canvas(220,220);grid.translate(10,20);grid.scale(2,2);background.drawCheckerboard(grid,220,220);
check(pixel(grid,109,70)[0]===90 && pixel(grid,110,70)[0]===80,'50-unit cells must scale to 100px and follow translation');
const source=canvas(8,8);source.fillStyle='rgb(128,64,192)';source.fillRect(0,0,8,8);
const destination=[80,120,160],color=[128,64,192];
for(const mode of ['source-over','lighter','multiply','screen']) for(const alpha of [1,0.5]){
const target=canvas(64,64);target.fillStyle='rgb(80,120,160)';target.fillRect(0,0,64,64);
const raster=rasterizer.createRasterizer(target);check(raster&&raster.begin(),'WebGL unavailable');
target.globalAlpha=alpha;target.globalCompositeOperation=mode;
raster.triangle(source.canvas,8,8,0,0,56,8,1,0,56,56,1,1);
raster.triangle(source.canvas,8,8,0,0,56,56,1,1,8,56,0,1);raster.end();
const actual=pixel(target,32,32);
for(let channel=0;channel<3;channel++){
const s=color[channel]*alpha,d=destination[channel];
const expected=mode==='lighter'?Math.min(255,s+d):mode==='multiply'?d*(s/255+1-alpha):mode==='screen'?s+d*(1-s/255):s+d*(1-alpha);
check(Math.abs(actual[channel]-expected)<2,mode+' '+alpha+' channel '+channel+': '+actual[channel]+' vs '+expected);
}
check(actual[3]===255,'background alpha must remain opaque');
target.globalAlpha=1;target.globalCompositeOperation='source-over';target.fillStyle='#ff0000';target.fillRect(0,0,64,64);
raster.begin();raster.end();check(pixel(target,32,32)[0]===255&&pixel(target,32,32)[1]===0,'background texture must refresh each frame');
raster.dispose();
}
const stamp=canvas(400,400);let text='';const fillText=stamp.fillText.bind(stamp);stamp.fillText=(s,...args)=>{text=s;fillText(s,...args)};
badge.drawVersion(stamp,400,400,'4.3.75-beta');check(text==='4.3.75-beta','beta version watermark');
let white=0;for(const value of stamp.getImageData(12,354,170,34).data)if(value>200)white++;
check(white>100,'watermark pixels');
document.getElementById('result').textContent='PASS: moving/scaling 50-unit checkerboard, four blend modes at two opacities, background refresh, 4.3 beta watermark';
}catch(error){document.getElementById('result').textContent='FAIL: '+error.stack}
</script>