UNPKG

unity-find-fault

Version:

A tool to find fault in unity project.

298 lines 13.9 kB
import fg from "fast-glob"; import fs from "fs-extra"; import { default as ssim } from "image-ssim"; import path from "path"; import sizeOf from "image-size"; import { UnityHelper } from "./UnityHelper.js"; import { loadImage } from "./loadImage.js"; import { Status } from "../tools/Status.js"; import { toolchain } from "../toolchain.js"; export class ImageDedup { dupGuideText = `########## INSTRUCMENT ########## # 正如现在所看到的,以#号开头均为注释 # # 本日志由命令\`unity-find-fault -p {PROJECT} -a dedupImage -o {OUTPUT}\`生成 # 可使用命令\`unity-find-fault -p {PROJECT} -a dedupImage --delete-instruction {OUTPUT}\`执行资源删除 # # 执行删除命令前,请先详细查看每组资源确定是否应当删除,如不能删除(比如代码中会动态加载),则将其从本日志中删去 # 编辑本日志时注意保持格式 # # +号开头表示保留该资源,-号开头表示该资源将被替换并删除 # +/-后括号内的数字表示该资源的静态引用次数,如果整组资源的静态引用次数均为0,则整组全部删除,不论是否以+号开头 # # 通常来说,模型贴图如果静态引用次数为0,都是可以删除的 # 但需注意某些资源虽然静态引用次数为0,但可能存在被代码动态加载的情况,比如: # Assets/Arts/platIcon下的Icon # Assets/AssetSources/icon下的图标 # Assets/AssetSources/ui/atlas下的某些图集文件(通常以数字有规律地命名) ########## ########### ##########`; diffGuideText = `########## INSTRUCMENT ########## # 以下图片均为图像相同但import类型设置不同的图片,比如图片A是Sprite而图片B是Texture,需要程序修改使用方式后才可进行替换删除 ########## ########### ##########`; iconGuideText = `########## INSTRUCMENT ########## # 以下图片均为程序根据表格配置读取的重复图标,请策划修改表格并删除其中重复的图片 ########## ########### ##########`; mixIconGuideText = `########## INSTRUCMENT ########## # 以下图片均为程序根据表格配置读取的重复图标,这些图标分别存在于不同的文件夹中,需要程序修改统一使用方式后才可进行替换删除 # 推荐方案是保留icon图标,修改使用其他图标的地方 ########## ########### ##########`; unknownGuideText = `########## INSTRUCMENT ########## # 以下图片均为程序无法辨识的图片,请人工甄别是否重复 ########## ########### ##########`; groupMap = {}; async execute() { if (toolchain.opts.deleteInstruction == null) { // 不删除,只输出重复图片信息 await this.exportDuplicated(); } else { await this.replaceByInstruction(); } } async exportDuplicated() { if (toolchain.opts.output == null) { console.error('No output specified.'); return; } // 确定icon路径 const iconPath = path.join(toolchain.opts.projectRoot, 'Assets/AssetSources/icon'); if (!fs.existsSync(iconPath)) { console.error('Cannot reach icon path!'); return; } const roots = toolchain.opts.input ? toolchain.opts.input.split(',').map(v => path.join(toolchain.opts.projectRoot, v)) : [path.join(toolchain.opts.projectRoot, 'Assets')]; // 目前只能处理png for (const root of roots) { const imgs = await fg(['**/*.png', '**/*.jpg', '**/*.tga'], { cwd: root }); // 先按照size/width/height归类 for (const img of imgs) { const file = path.join(root, img); try { const dimensions = await sizeOf(file); const fstat = await fs.stat(file); const key = `${path.extname(img).toLowerCase()}_${fstat.size}_${dimensions.width}_${dimensions.height}`; let group = this.groupMap[key]; if (group == null) { this.groupMap[key] = group = []; } group.push({ file, img: null }); } catch (e) { console.error('get dimesion failed:', file); } } } // 比较 Status.Instance.startTask('Compare images', Object.keys(this.groupMap).length); const duplicates = []; const diffSettings = []; const allIcons = []; const mixIcons = []; const unkowns = []; for (const key in this.groupMap) { Status.Instance.update(key); const group = this.groupMap[key]; const cnt = group.length; if (cnt == 1) continue; for (const info of group) { const img = await loadImage(info.file); info.img = img; } // 检查如果是不受支持的图片类型,则列入suspected if (group[0].img == null) { unkowns.push(group.map((v) => v.file)); continue; } const dupMap = {}; const diffSettingMap = {}; for (let i = 0; i < cnt - 1; i++) { const infoA = group[i]; if (infoA.similarTo) continue; // const metaA = await UnityHelper.loadMeta<Unity.Meta.Texture>(infoA.file + '.meta'); const metaA = await UnityHelper.readTextureMeta(infoA.file + '.meta'); for (let j = i + 1; j < cnt; j++) { const infoB = group[j]; if (infoB.similarTo) continue; // 再比较图片相似度 if (infoA.img != null && infoB.img != null) { const rst = ssim.compare(infoA.img, infoB.img); // console.log(infoA.file, infoB.file, rst); if (rst.ssim == 1) { // 再比较图片设置 // const metaB = await UnityHelper.loadMeta<Unity.Meta.Texture>(infoB.file + '.meta'); const metaB = await UnityHelper.readTextureMeta(infoB.file + '.meta'); // if (metaA.TextureImporter?.textureType != metaB.TextureImporter?.textureType) { if (metaA.textureType != metaB.textureType || metaA.spriteBorder.x != metaB.spriteBorder.x || metaA.spriteBorder.y != metaB.spriteBorder.y || metaA.spriteBorder.z != metaB.spriteBorder.z || metaA.spriteBorder.w != metaB.spriteBorder.w) { // 先比较texture type let diffs = diffSettingMap[infoA.file]; if (diffs == null) { diffSettingMap[infoA.file] = diffs = []; } diffs.push(infoB.file); continue; } let dups = dupMap[infoA.file]; if (dups == null) { dupMap[infoA.file] = dups = []; } dups.push(infoB.file); infoB.similarTo = infoA.file; } } } } for (const file in dupMap) { const dups = dupMap[file]; dups.unshift(file); // 判断分类 let iconCnt = 0; for (const d of dups) { if (d.includes(iconPath)) iconCnt++; } const dcnt = dups.length; if (iconCnt == dcnt) { allIcons.push(dups); } else if (iconCnt > 0) { mixIcons.push(dups); } else { duplicates.push(dups); } } for (const file in diffSettingMap) { const diffs = diffSettingMap[file]; diffs.unshift(file); duplicates.push(diffs); } // 卸载所有图片数据防止内存撑爆 group.forEach((v) => v.img = null); } Status.Instance.end(); // 输出重复图片信息 await this.dump(duplicates, this.dupGuideText, toolchain.opts.output); // 输出设置不同的重复图片信息 await this.dump(diffSettings, this.diffGuideText, this.getOutput(toolchain.opts.output, 'diffSetting')); // 输出重复图标信息 await this.dump(allIcons, this.iconGuideText, this.getOutput(toolchain.opts.output, 'icon')); // 输出混合图标信息 await this.dump(mixIcons, this.mixIconGuideText, this.getOutput(toolchain.opts.output, '.mixIcon')); // 目前只支持png/jpg/tga,输出其他不受支持的图片类型 await this.dump(unkowns, this.unknownGuideText, this.getOutput(toolchain.opts.output, 'unknown')); } async dump(duplicates, guideText, output) { const allFiles = duplicates.flat(); // 按引用次数排序 const refMap = await this.countRef(allFiles); for (const dups of duplicates) { dups.sort((a, b) => refMap[b] - refMap[a]); // console.log('------------'); // dups.forEach((value, index) => console.log(`${index == 0 ? '+' : '-'}(${refMap[value]}) file:///${value}`)); } if (output != null) { let content = guideText.replaceAll('{PROJECT}', toolchain.opts.projectRoot).replaceAll('{OUTPUT}', toolchain.opts.output); for (const dups of duplicates) { content += '------------\n'; dups.forEach((value, index) => content += `${index == 0 ? '+' : '-'}(${refMap[value]}) file:///${value}\n`); } await fs.ensureFile(output); await fs.writeFile(output, content, 'utf-8'); } } async countRef(files) { const refMap = {}; const guidMap = {}; for (const file of files) { guidMap[file] = await UnityHelper.readGUID(file + '.meta'); refMap[file] = 0; } // 检查所有prefab和material const checkRoot = path.join(toolchain.opts.projectRoot, 'Assets'); const checkedFiles = await fg(['**/*.prefab', '**/*.mat'], { cwd: checkRoot }); for (const f of checkedFiles) { const file = path.join(checkRoot, f); const content = await fs.readFile(file, 'utf-8'); for (const file of files) { const results = content.matchAll(new RegExp(guidMap[file], 'g')); for (const rst of results) { refMap[file] = refMap[file] + 1; } } } return refMap; } async replaceByInstruction() { // 将需要删除的图片挑选出来存入指定txt中并读取删除 const dinfos = await this.readDeleteInstruction(toolchain.opts.deleteInstruction); // 检查所有prefab和material const checkRoot = path.join(toolchain.opts.projectRoot, 'Assets'); const checkedFiles = await fg(['**/*.prefab', '**/*.mat'], { cwd: checkRoot }); for (const f of checkedFiles) { const file = path.join(checkRoot, f); const content = await fs.readFile(file, 'utf-8'); let newContent = content; for (const dinfo of dinfos) { if (!dinfo.reservedGUID) continue; for (const td of dinfo.deleteGUIDs) { newContent = newContent.replaceAll(td, dinfo.reservedGUID); } } if (newContent != content) { console.log('file modified:', file); await fs.writeFile(file, newContent, 'utf-8'); } } for (const dinfo of dinfos) { for (const td of dinfo.deleteFiles) { await fs.unlink(td); await fs.unlink(td + '.meta'); } } } async readDeleteInstruction(instFile) { const dicontent = await fs.readFile(instFile, 'utf-8'); const lines = dicontent.split(/\r?\n/); const dinfos = []; let dinfo = null; for (const line of lines) { if (line.startsWith('#')) continue; if (line.startsWith('---')) { dinfo = null; } else { const f = line.replace(/[\+\-]\(\d+\) file:\/\/\//, ''); if (fs.existsSync(f)) { if (dinfo == null) { dinfo = { reservedFile: '', deleteFiles: [], reservedGUID: '', deleteGUIDs: [] }; dinfos.push(dinfo); } const guid = await UnityHelper.readGUID(f + '.meta'); if (line.startsWith('+')) { dinfo.reservedFile = f; dinfo.reservedGUID = guid; } else { dinfo.deleteFiles.push(f); dinfo.deleteGUIDs.push(guid); } } } } return dinfos; } getOutput(original, desc) { const outExt = path.extname(original); return outExt != '' ? original.replace(outExt, `.${desc}` + outExt) : original + `.${desc}`; } } //# sourceMappingURL=ImageDedup.js.map