main.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. const Fs = require('fs');
  2. const Path = require('path');
  3. const Os = require('os');
  4. const { exec } = require('child_process');
  5. const ConfigManager = require('./config-manager');
  6. const FileUtils = require('./utils/file-utils');
  7. /** 包名 */
  8. const PACKAGE_NAME = require('./package.json').name;
  9. /**
  10. * i18n
  11. * @param {string} key
  12. * @returns {string}
  13. */
  14. const translate = (key) => Editor.T(`${PACKAGE_NAME}.${key}`);
  15. /** 扩展名 */
  16. const EXTENSION_NAME = translate('name');
  17. /** 内置资源目录 */
  18. const internalPath = Path.normalize('assets/internal/');
  19. module.exports = {
  20. /**
  21. * 项目路径
  22. * @type {string}
  23. */
  24. projectPath: null,
  25. /**
  26. * 资源根目录路径
  27. * @type {string}
  28. */
  29. assetsPath: null,
  30. /**
  31. * 压缩引擎路径
  32. * @type {string}
  33. */
  34. pngquantPath: null,
  35. /**
  36. * 日志
  37. * @type {{ successCount: number, failedCount: number, successInfo: string, failedInfo: string }}
  38. */
  39. logger: null,
  40. /**
  41. * 需要排除的文件夹
  42. * @type {string[]}
  43. */
  44. excludeFolders: null,
  45. /**
  46. * 需要排除的文件
  47. * @type {string[]}
  48. */
  49. excludeFiles: null,
  50. /**
  51. * 扩展消息
  52. * @type {{ [key: string]: Function }}
  53. */
  54. messages: {
  55. /**
  56. * 打开设置面板
  57. */
  58. 'open-setting-panel'() {
  59. Editor.Panel.open(`${PACKAGE_NAME}.setting`);
  60. },
  61. /**
  62. * 读取配置
  63. * @param {any} event
  64. */
  65. 'read-config'(event) {
  66. const config = ConfigManager.get();
  67. event.reply(null, config);
  68. },
  69. /**
  70. * 保存配置
  71. * @param {any} event
  72. * @param {any} config
  73. */
  74. 'save-config'(event, config) {
  75. const configFilePath = ConfigManager.set(config);
  76. Editor.log(`[${EXTENSION_NAME}]`, translate('configSaved'), configFilePath);
  77. event.reply(null, true);
  78. },
  79. },
  80. /**
  81. * 生命周期:加载
  82. */
  83. load() {
  84. // 绑定 this
  85. this.onBuildStart = this.onBuildStart.bind(this);
  86. this.onBuildFinished = this.onBuildFinished.bind(this);
  87. // 监听事件
  88. Editor.Builder.on('build-start', this.onBuildStart);
  89. Editor.Builder.on('build-finished', this.onBuildFinished);
  90. },
  91. /**
  92. * 生命周期:加载
  93. */
  94. unload() {
  95. // 取消事件监听
  96. Editor.Builder.removeListener('build-start', this.onBuildStart);
  97. Editor.Builder.removeListener('build-finished', this.onBuildFinished);
  98. },
  99. /**
  100. * 构建开始回调
  101. * @param {BuildOptions} options
  102. * @param {Function} callback
  103. */
  104. onBuildStart(options, callback) {
  105. const config = ConfigManager.get();
  106. if (config && config.enabled) {
  107. Editor.log(`[${EXTENSION_NAME}]`, translate('willCompress'));
  108. // 取消编辑器资源选中(解除占用)
  109. Editor.Selection.clear('asset');
  110. }
  111. // Done
  112. callback();
  113. },
  114. /**
  115. * 构建完成回调
  116. * @param {BuildOptions} options
  117. * @param {Function} callback
  118. */
  119. async onBuildFinished(options, callback) {
  120. const config = ConfigManager.get();
  121. // 未开启直接跳过
  122. if (!config || !config.enabled) {
  123. callback();
  124. return;
  125. }
  126. // 获取项目路径
  127. this.projectPath = Editor.Project.path || Editor.projectPath;
  128. this.assetsPath = Path.join(this.projectPath, 'assets');
  129. // 获取压缩引擎路径
  130. const platform = Os.platform(),
  131. pngquantPath = this.pngquantPath = Path.join(__dirname, enginePathMap[platform]);
  132. // 设置引擎文件的执行权限(仅 macOS)
  133. if (pngquantPath && platform === 'darwin') {
  134. if (Fs.statSync(pngquantPath).mode != 33261) {
  135. // 默认为 33188
  136. Fs.chmodSync(pngquantPath, 33261);
  137. }
  138. }
  139. // 压缩引擎路径
  140. if (!pngquantPath) {
  141. Editor.log(`[${EXTENSION_NAME}]`, translate('notSupport'), platform);
  142. callback();
  143. return;
  144. }
  145. // 准备
  146. Editor.log(`[${EXTENSION_NAME}]`, translate('prepareCompress'));
  147. // 组装压缩命令
  148. const qualityParam = `--quality ${config.minQuality}-${config.maxQuality}`,
  149. speedParam = `--speed ${config.speed}`,
  150. skipParam = '--skip-if-larger',
  151. outputParam = '--ext=.png',
  152. writeParam = '--force',
  153. // colorsParam = config.colors,
  154. // compressOptions = `${qualityParam} ${speedParam} ${skipParam} ${outputParam} ${writeParam} ${colorsParam}`;
  155. compressOptions = `${qualityParam} ${speedParam} ${skipParam} ${outputParam} ${writeParam}`;
  156. // 需要排除的文件夹
  157. this.excludeFolders = config.excludeFolders ? config.excludeFolders.map(value => Path.normalize(value)) : [];
  158. // 需要排除的文件
  159. this.excludeFiles = config.excludeFiles ? config.excludeFiles.map(value => Path.normalize(value)) : [];
  160. // 重置日志
  161. this.logger = {
  162. successCount: 0,
  163. failedCount: 0,
  164. successInfo: '',
  165. failedInfo: ''
  166. };
  167. // 开始压缩
  168. Editor.log(`[${EXTENSION_NAME}]`, translate('startCompress'));
  169. // 遍历项目资源
  170. const dest = options.dest,
  171. dirs = ['res', 'assets', 'subpackages', 'remote'];
  172. for (let i = 0; i < dirs.length; i++) {
  173. const dirPath = Path.join(dest, dirs[i]);
  174. if (!Fs.existsSync(dirPath)) {
  175. continue;
  176. }
  177. Editor.log(`[${EXTENSION_NAME}]`, translate('compressDir'), dirPath);
  178. // 压缩并记录结果
  179. await this.compress(dirPath, compressOptions);
  180. }
  181. // 打印压缩结果
  182. this.printResults();
  183. // Done
  184. callback();
  185. },
  186. /**
  187. * 压缩
  188. * @param {string} srcPath 文件路径
  189. * @param {object} options 压缩参数
  190. */
  191. async compress(srcPath, options) {
  192. const pngquantPath = this.pngquantPath,
  193. tasks = [];
  194. const handler = (filePath, stats) => {
  195. // 过滤文件
  196. if (!this.filter(filePath)) {
  197. return;
  198. }
  199. // 加入压缩队列
  200. tasks.push(new Promise(res => {
  201. const sizeBefore = stats.size / 1024,
  202. command = `"${pngquantPath}" ${options} -- "${filePath}"`;
  203. // pngquant $OPTIONS -- "$FILE"
  204. exec(command, (error, stdout, stderr) => {
  205. this.recordResult(error, sizeBefore, filePath);
  206. res();
  207. });
  208. }));
  209. };
  210. FileUtils.map(srcPath, handler);
  211. await Promise.all(tasks);
  212. },
  213. /**
  214. * 判断资源是否可以进行压缩
  215. * @param {string} path 路径
  216. */
  217. filter(path) {
  218. // 排除非 png 资源和内置资源
  219. if (!path.endsWith('.png') || path.includes(internalPath)) {
  220. return false;
  221. }
  222. // 排除指定文件夹和文件
  223. const assetPath = this.getAssetPath(path);
  224. if (assetPath) {
  225. const excludeFolders = this.excludeFolders,
  226. excludeFiles = this.excludeFiles;
  227. // 文件夹
  228. for (let i = 0, l = excludeFolders.length; i < l; i++) {
  229. if (assetPath.startsWith(excludeFolders[i])) {
  230. return false;
  231. }
  232. }
  233. // 文件
  234. for (let i = 0, l = excludeFiles.length; i < l; i++) {
  235. if (assetPath.startsWith(excludeFiles[i])) {
  236. return false;
  237. }
  238. }
  239. }
  240. // 测试通过
  241. return true;
  242. },
  243. /**
  244. * 获取资源源路径
  245. * @param {string} filePath
  246. * @return {string}
  247. */
  248. getAssetPath(filePath) {
  249. // 获取源路径(图像在项目中的实际路径)
  250. const basename = Path.basename(filePath),
  251. uuid = basename.slice(0, basename.indexOf('.')),
  252. sourcePath = Editor.assetdb.uuidToFspath(uuid);
  253. if (!sourcePath) {
  254. // 图集资源
  255. // 暂时还没有找到办法处理
  256. return null;
  257. }
  258. return Path.relative(this.assetsPath, sourcePath);
  259. },
  260. /**
  261. * 记录结果
  262. * @param {object} error 错误
  263. * @param {number} sizeBefore 压缩前尺寸
  264. * @param {string} filePath 文件路径
  265. */
  266. recordResult(error, sizeBefore, filePath) {
  267. const log = this.logger;
  268. if (!error) {
  269. // 成功
  270. const fileName = Path.basename(filePath),
  271. sizeAfter = Fs.statSync(filePath).size / 1024,
  272. savedSize = sizeBefore - sizeAfter,
  273. savedRatio = savedSize / sizeBefore * 100;
  274. log.successCount++;
  275. log.successInfo += `\n + ${'Successful'.padEnd(13, ' ')} | ${fileName.padEnd(50, ' ')} | ${(sizeBefore.toFixed(2) + ' KB').padEnd(13, ' ')} -> ${(sizeAfter.toFixed(2) + ' KB').padEnd(13, ' ')} | ${(savedSize.toFixed(2) + ' KB').padEnd(13, ' ')} | ${(savedRatio.toFixed(2) + '%').padEnd(20, ' ')}`;
  276. } else {
  277. // 失败
  278. log.failedCount++;
  279. log.failedInfo += `\n - ${'Failed'.padEnd(13, ' ')} | ${filePath.replace(this.projectPath, '')}`;
  280. switch (error.code) {
  281. case 98:
  282. log.failedInfo += `\n ${' '.repeat(10)} - 失败原因:压缩后体积增大(已经不能再压缩了)`;
  283. break;
  284. case 99:
  285. log.failedInfo += `\n ${' '.repeat(10)} - 失败原因:压缩后质量低于已配置最低质量`;
  286. break;
  287. case 127:
  288. log.failedInfo += `\n ${' '.repeat(10)} - 失败原因:压缩引擎没有执行权限`;
  289. break;
  290. default:
  291. log.failedInfo += `\n ${' '.repeat(10)} - 失败原因:code ${error.code}`;
  292. break;
  293. }
  294. }
  295. },
  296. /**
  297. * 打印结果
  298. */
  299. printResults() {
  300. const log = this.logger,
  301. header = `\n # ${'Result'.padEnd(13, ' ')} | ${'Name / Path'.padEnd(50, ' ')} | ${'Size Before'.padEnd(13, ' ')} -> ${'Size After'.padEnd(13, ' ')} | ${'Saved Size'.padEnd(13, ' ')} | ${'Compressibility'.padEnd(20, ' ')}`;
  302. Editor.log('[PAC]', `压缩完成(${log.successCount} 张成功 | ${log.failedCount} 张失败)`);
  303. Editor.log('[PAC]', '压缩日志 >>>' + header + log.successInfo + log.failedInfo);
  304. },
  305. }
  306. /** 压缩引擎路径表 */
  307. const enginePathMap = {
  308. /** macOS */
  309. 'darwin': 'pngquant/macos/pngquant',
  310. /** Windows */
  311. 'win32': 'pngquant/windows/pngquant'
  312. }