unity-find-fault
Version:
A tool to find fault in unity project.
298 lines • 13.9 kB
JavaScript
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