From 51c36b1d4338925d693bfd69346ef305312edc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8C=E6=95=A6=E4=BC=9F?= Date: Fri, 3 Apr 2026 17:18:52 +0800 Subject: [PATCH 1/3] feat: support linux asset toolchain resolution --- .../asset-handler/assets/erp-texture-cube.ts | 4 +- .../assets/gltf/reader-manager.ts | 10 +- .../asset-handler/assets/image/image-mics.ts | 346 ++++++++++++++++-- .../asset-handler/assets/image/index.ts | 10 +- .../asset-handler/assets/image/utils.ts | 20 +- .../assets/instantiation-asset/asset.ts | 4 +- .../assets/asset-handler/assets/utils/cmft.ts | 35 ++ .../assets/utils/fbx-converter.ts | 51 ++- .../asset-handler/assets/utils/fbx2glTf.ts | 47 ++- .../assets/utils/tool-resolver.ts | 63 ++++ .../asset-handler/assets/utils/unzip.ts | 31 ++ .../asset-handler/assets/utils/uv-unwrap.ts | 23 +- workflow/download-tools.js | 343 ++++++++++------- workflow/linux-doctor.sh | 155 ++++++++ 14 files changed, 941 insertions(+), 201 deletions(-) create mode 100644 src/core/assets/asset-handler/assets/utils/cmft.ts create mode 100644 src/core/assets/asset-handler/assets/utils/tool-resolver.ts create mode 100644 src/core/assets/asset-handler/assets/utils/unzip.ts create mode 100644 workflow/linux-doctor.sh diff --git a/src/core/assets/asset-handler/assets/erp-texture-cube.ts b/src/core/assets/asset-handler/assets/erp-texture-cube.ts index 86bbbb795..f5e119f96 100644 --- a/src/core/assets/asset-handler/assets/erp-texture-cube.ts +++ b/src/core/assets/asset-handler/assets/erp-texture-cube.ts @@ -15,8 +15,8 @@ import { getDependUUIDList } from '../utils'; import { AssetHandler } from '../../@types/protected'; import { TextureCubeAssetUserData } from '../../@types/userDatas'; import { makeDefaultTextureCubeAssetUserData } from './image/utils'; -import { GlobalPaths } from '../../../../global'; import utils from '../../../base/utils'; +import { resolveCmftTool } from './utils/cmft'; type ITextureCubeMipMap = cc.TextureCube['mipmaps'][0]; @@ -142,7 +142,7 @@ export const ERPTextureCubeHandler: AssetHandler = { console.log(`Start to bake asset {asset[${asset.uuid}](${asset.uuid})}`); - const cmdTool = join(GlobalPaths.staticDir, 'tools/cmft/cmftRelease64') + (process.platform === 'win32' ? '.exe' : ''); + const cmdTool = resolveCmftTool(); await utils.Process.quickSpawn(cmdTool, vectorParams, { stdio: 'inherit', }); diff --git a/src/core/assets/asset-handler/assets/gltf/reader-manager.ts b/src/core/assets/asset-handler/assets/gltf/reader-manager.ts index d9176d30d..273029084 100644 --- a/src/core/assets/asset-handler/assets/gltf/reader-manager.ts +++ b/src/core/assets/asset-handler/assets/gltf/reader-manager.ts @@ -8,10 +8,11 @@ import { fork } from 'child_process'; import path from 'path'; import { GlobalPaths } from '../../../../../global'; import assetConfig from '../../../asset-config'; -import { createFbxConverter } from '../utils/fbx-converter'; +import { createFbxConverter, hasFbxGlTfConvTool } from '../utils/fbx-converter'; import { modelConvertRoutine } from '../utils/model-convert-routine'; import { fbxToGlTf } from './fbx-to-gltf'; import { I18nKeys } from '../../../../../i18n/types/generated'; +import { hasFbx2GltfTool } from '../utils/fbx2glTf'; class GlTfReaderManager { private _map = new Map(); @@ -49,7 +50,12 @@ export async function getFbxFilePath(asset: Asset, importerVersion: string,) { (userData.fbx ??= {}).smartMaterialEnabled = await assetConfig.getProject('fbx.material.smart') ?? false; } let outGLTFFile: string; - if (userData.legacyFbxImporter) { + const shouldUseLegacyImporter = userData.legacyFbxImporter + || (process.platform === 'linux' && !hasFbxGlTfConvTool() && hasFbx2GltfTool()); + if (shouldUseLegacyImporter) { + if (!userData.legacyFbxImporter && process.platform === 'linux') { + console.warn('FBX-glTF-conv is unavailable on Linux, fallback to FBX2glTF.'); + } outGLTFFile = await fbxToGlTf(asset, asset._assetDB, importerVersion); } else { const options: Parameters[0] = {}; diff --git a/src/core/assets/asset-handler/assets/image/image-mics.ts b/src/core/assets/asset-handler/assets/image/image-mics.ts index 0d05a9d2b..d78117e36 100644 --- a/src/core/assets/asset-handler/assets/image/image-mics.ts +++ b/src/core/assets/asset-handler/assets/image/image-mics.ts @@ -1,14 +1,30 @@ 'use strict'; -import { join, dirname, basename, extname, sep, normalize } from 'path'; +import { join, dirname, basename, extname, sep, normalize, delimiter } from 'path'; import { PNG } from 'pngjs'; import TGA from 'tga-js'; -import { readFile, ensureDirSync, existsSync } from 'fs-extra'; +import { readFile, ensureDirSync, remove, writeFile } from 'fs-extra'; import PSD from 'psd.js'; import Sharp from 'sharp'; import { GlobalPaths } from '../../../../../global'; import utils from '../../../../base/utils'; +import { resolveCmftTool } from '../utils/cmft'; +import { resolveExecutable } from '../utils/tool-resolver'; + +export interface ICmftConvertResult { + extName: string; + source: string; + isRGBE: boolean; +} + +interface IHDRImageData { + width: number; + height: number; + data: Buffer; +} + +const PNG_RGBE_BASE = 1.1; export async function convertTGA(data: Buffer): Promise<{ extName: string; data: Buffer }> { const tga = new TGA(); @@ -20,26 +36,32 @@ export async function convertTGA(data: Buffer): Promise<{ extName: string; data: } export async function convertImageToHDR(file: string, uuid: string, temp: string) { - // const output = join(temp, uuid + '.hdr'); const output = join(temp, uuid + '.hdr'); ensureDirSync(dirname(output)); - // https://github.com/ImageMagick/ImageMagick - let convertTool = join(GlobalPaths.staticDir, 'tools/mali_darwin/convert'); - if (process.platform === 'win32') { - convertTool = join(GlobalPaths.staticDir, 'tools/mali_win32/convert.exe'); + const bundledTool = process.platform === 'win32' + ? join(GlobalPaths.staticDir, 'tools/mali_win32/convert.exe') + : process.platform === 'darwin' + ? join(GlobalPaths.staticDir, 'tools/mali_darwin/convert') + : join(GlobalPaths.staticDir, 'tools/mali_linux/convert'); + const convertTool = resolveExecutable({ + fileCandidates: [bundledTool], + }); + + if (!convertTool) { + throw new Error( + process.platform === 'linux' + ? 'Unable to locate the Linux convert tool. Place the binary at `static/tools/mali_linux/convert`.' + : 'Unable to locate ImageMagick convert tool.', + ); } + const toolDir = dirname(convertTool); - convertTool = '.' + sep + basename(convertTool); const env = Object.assign({}, process.env); - // convert 是 imagemagick 中的一个工具 - // etcpack 中应该是以 'convert' 而不是 './convert' 来调用工具的,所以需要将 toolDir 加到环境变量中 - // toolDir 需要放在前面,以防止系统找到用户自己安装的 imagemagick 版本 - env.PATH = toolDir + ':' + env.PATH; - await utils.Process.quickSpawn(convertTool, [normalize(file), normalize(output)], { - // windows 中需要进入到 toolDir 去执行命令才能成功 + env.PATH = env.PATH ? `${toolDir}${delimiter}${env.PATH}` : toolDir; + await utils.Process.quickSpawn('.' + sep + basename(convertTool), [normalize(file), normalize(output)], { cwd: toolDir, - env: env, + env, }); return { @@ -89,6 +111,225 @@ async function savePNGObject(png: PNG) { }); } +function readHDRLine(data: Buffer, offset: number) { + const end = data.indexOf(0x0a, offset); + if (end < 0) { + throw new Error('Unexpected end of HDR file.'); + } + + let line = data.toString('ascii', offset, end); + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } + + return { + line, + offset: end + 1, + }; +} + +function decodeOldHDRPixels(data: Buffer, offset: number, width: number, height: number, firstPixel?: number[]): IHDRImageData { + const pixelCount = width * height; + const output = Buffer.alloc(pixelCount * 4); + let writeOffset = 0; + let shift = 0; + let pendingPixel = firstPixel; + + while (writeOffset < output.length) { + let pixel: number[]; + if (pendingPixel) { + pixel = pendingPixel; + pendingPixel = undefined; + } else { + if (offset + 4 > data.length) { + throw new Error('Unexpected end of HDR scanline data.'); + } + pixel = [data[offset++], data[offset++], data[offset++], data[offset++]]; + } + + if (pixel[0] === 1 && pixel[1] === 1 && pixel[2] === 1) { + if (writeOffset === 0) { + throw new Error('Invalid HDR repeat marker.'); + } + + const repeat = pixel[3] << shift; + shift += 8; + const prevOffset = writeOffset - 4; + for (let index = 0; index < repeat; index++) { + output.copy(output, writeOffset, prevOffset, prevOffset + 4); + writeOffset += 4; + if (writeOffset > output.length) { + throw new Error('HDR repeat marker exceeds image size.'); + } + } + continue; + } + + shift = 0; + output[writeOffset++] = pixel[0]; + output[writeOffset++] = pixel[1]; + output[writeOffset++] = pixel[2]; + output[writeOffset++] = pixel[3]; + } + + return { + width, + height, + data: output, + }; +} + +function parseRGBEHDR(data: Buffer): IHDRImageData { + let cursor = 0; + let lineData = readHDRLine(data, cursor); + cursor = lineData.offset; + + if (!lineData.line.startsWith('#?')) { + throw new Error('Invalid HDR signature.'); + } + + let hasFormat = false; + while (true) { + lineData = readHDRLine(data, cursor); + cursor = lineData.offset; + if (!lineData.line) { + break; + } + if (lineData.line === 'FORMAT=32-bit_rle_rgbe') { + hasFormat = true; + } + } + + if (!hasFormat) { + throw new Error('Unsupported HDR format.'); + } + + lineData = readHDRLine(data, cursor); + cursor = lineData.offset; + + const resolution = /^-Y\s+(\d+)\s+\+X\s+(\d+)$/.exec(lineData.line); + if (!resolution) { + throw new Error(`Unsupported HDR resolution line: ${lineData.line}`); + } + + const height = Number(resolution[1]); + const width = Number(resolution[2]); + + if (width < 8 || width > 0x7fff) { + return decodeOldHDRPixels(data, cursor, width, height); + } + + const output = Buffer.alloc(width * height * 4); + + for (let y = 0; y < height; y++) { + if (cursor + 4 > data.length) { + throw new Error('Unexpected end of HDR scanline header.'); + } + + const header = [data[cursor++], data[cursor++], data[cursor++], data[cursor++]]; + if (header[0] !== 2 || header[1] !== 2 || (header[2] & 0x80) !== 0) { + if (y !== 0) { + throw new Error('Mixed HDR scanline encodings are not supported.'); + } + return decodeOldHDRPixels(data, cursor, width, height, header); + } + + const scanlineWidth = (header[2] << 8) | header[3]; + if (scanlineWidth !== width) { + throw new Error(`Invalid HDR scanline width ${scanlineWidth}, expected ${width}.`); + } + + const scanline = [Buffer.alloc(width), Buffer.alloc(width), Buffer.alloc(width), Buffer.alloc(width)]; + for (let channel = 0; channel < 4; channel++) { + let x = 0; + while (x < width) { + if (cursor >= data.length) { + throw new Error('Unexpected end of HDR RLE data.'); + } + + const count = data[cursor++]; + if (count > 128) { + const runLength = count - 128; + if (runLength === 0 || x + runLength > width || cursor >= data.length) { + throw new Error('Unexpected end of HDR RLE run.'); + } + const value = data[cursor++]; + scanline[channel].fill(value, x, x + runLength); + x += runLength; + continue; + } + + if (count === 0 || x + count > width || cursor + count > data.length) { + throw new Error('Invalid HDR literal run.'); + } + + data.copy(scanline[channel], x, cursor, cursor + count); + cursor += count; + x += count; + } + } + + for (let x = 0; x < width; x++) { + const outputOffset = (y * width + x) * 4; + output[outputOffset + 0] = scanline[0][x]; + output[outputOffset + 1] = scanline[1][x]; + output[outputOffset + 2] = scanline[2][x]; + output[outputOffset + 3] = scanline[3][x]; + } + } + + return { + width, + height, + data: output, + }; +} + +export async function convertRGBEHDR(data: Buffer): Promise<{ extName: string; data: Buffer }> { + const imageData = parseRGBEHDR(data); + const encoded = Buffer.alloc(imageData.data.length); + + for (let index = 0; index < imageData.data.length; index += 4) { + const exponent = imageData.data[index + 3]; + if (!exponent) { + continue; + } + + const scale = Math.pow(2, exponent - 136); + writeRGBEToPNG( + imageData.data[index + 0] * scale, + imageData.data[index + 1] * scale, + imageData.data[index + 2] * scale, + encoded, + index, + ); + } + + const png = new PNG({ width: imageData.width, height: imageData.height }); + png.data = encoded; + return await savePNGObject(png); +} + +function writeRGBEToPNG(r: number, g: number, b: number, output: Buffer, offset: number) { + const maxValue = Math.max(r, g, b); + if (maxValue <= 0) { + output[offset + 0] = 0; + output[offset + 1] = 0; + output[offset + 2] = 0; + output[offset + 3] = 0; + return; + } + + const exponent = Math.ceil(Math.log(maxValue) / Math.log(PNG_RGBE_BASE)); + const storedExponent = Math.min(Math.max(exponent + 128, 0), 255); + const scale = Math.pow(PNG_RGBE_BASE, storedExponent - 128); + + output[offset + 0] = Math.min(255, Math.max(0, Math.floor((r / scale) * 255))); + output[offset + 1] = Math.min(255, Math.max(0, Math.floor((g / scale) * 255))); + output[offset + 2] = Math.min(255, Math.max(0, Math.floor((b / scale) * 255))); + output[offset + 3] = storedExponent; +} + export async function convertHDROrEXR(extName: string, source: string, uuid: string, temp: string) { console.debug(`Start to convert asset {asset[${uuid}](${uuid})}`); const dist = join(temp, uuid); @@ -96,18 +337,15 @@ export async function convertHDROrEXR(extName: string, source: string, uuid: str if (extName === '.hdr') { return await convertWithCmft(source, dist); } else if (extName === '.exr') { - // 先尝试使用 cmft try { return await convertWithCmft(source, dist, '_withexr'); } catch (error) { - // 如果使用 cmft 直接转失败,则先转 hdr 再使用 cmft 处理 const res = await convertImageToHDR(source, uuid, temp); return await convertWithCmft(res.source, dist); } } } -// 兼容旧接口 export async function convertHDR(source: string, uuid: string, temp: string) { console.debug(`Start to convert asset {asset[${uuid}](${uuid})}`); const dist = join(temp, uuid); @@ -115,24 +353,66 @@ export async function convertHDR(source: string, uuid: string, temp: string) { return await convertWithCmft(source, dist); } -export async function convertWithCmft(file: string, dist: string, version = ''): Promise<{ extName: string; source: string }> { - // https://github.com/dariomanesku/cmft - let tools = join(GlobalPaths.staticDir, `tools/cmft/cmftRelease64${version}${process.platform === 'win32' ? '.exe' : ''}`); - if (!existsSync(tools)) { - tools = join(GlobalPaths.staticDir, `tools/cmft/cmftRelease64${process.platform === 'win32' ? '.exe' : ''}`); +export async function convertWithCmft(file: string, dist: string, version = ''): Promise { + const tools = resolveCmftTool(version); + const pngOutput = dist + '.png'; + + if (process.platform === 'linux') { + const hdrOutput = dist + '.hdr'; + await utils.Process.quickSpawn(tools, [ + '--bypassoutputtype', + '--output0params', + 'hdr,rgbe,latlong', + '--input', + file, + '--output0', + dist, + ]); + try { + const converted = await convertRGBEHDR(await readFile(hdrOutput)); + await writeFile(pngOutput, converted.data); + } finally { + await remove(hdrOutput); + } + } else { + await utils.Process.quickSpawn(tools, [ + '--bypassoutputtype', + '--output0params', + 'png,rgbm,latlong', + '--input', + file, + '--output0', + dist, + ]); } - await utils.Process.quickSpawn(tools, [ - '--bypassoutputtype', - '--output0params', - 'png,rgbm,latlong', - '--input', - file, - '--output0', - dist, - ]); + console.debug(`Convert asset${file} -> PNG success.`); return { extName: '.png', - source: dist + '.png', + source: pngOutput, + isRGBE: true, + }; +} + +export async function checkImageTypeByData(file: string) { + const data = await readFile(file); + const ext = extname(file).toLowerCase(); + let extName = ext; + + if (['.tga'].includes(ext)) { + extName = '.tga'; + } else if (['.psd'].includes(ext)) { + extName = '.psd'; + } else if (['.hdr'].includes(ext)) { + extName = '.hdr'; + } else if (['.exr'].includes(ext)) { + extName = '.exr'; + } else if (['.tif', '.tiff'].includes(ext)) { + extName = '.tif'; + } + + return { + extName, + data, }; } diff --git a/src/core/assets/asset-handler/assets/image/index.ts b/src/core/assets/asset-handler/assets/image/index.ts index f018b0077..1051493fb 100644 --- a/src/core/assets/asset-handler/assets/image/index.ts +++ b/src/core/assets/asset-handler/assets/image/index.ts @@ -38,7 +38,7 @@ export const ImageHandler: AssetHandler = { }, importer: { // 版本号如果变更,则会强制重新导入 - version: '1.0.27', + version: '1.0.28', /** * 是否强制刷新 * @param asset @@ -66,8 +66,7 @@ export const ImageHandler: AssetHandler = { extName = converted.extName; imageDataBufferOrimagePath = converted.source; - // bmp 导入的,默认钩上 isRGBE - userData.isRGBE = true; + userData.isRGBE = converted.isRGBE; // 对于 rgbe 类型图片默认关闭这个选项 userData.fixAlphaTransparencyArtifacts ||= false; } else if (extName === '.znt') { @@ -81,7 +80,7 @@ export const ImageHandler: AssetHandler = { imageDataBufferOrimagePath = converted.source; // 对于 rgbe 类型图片默认关闭这个选项 userData.fixAlphaTransparencyArtifacts = false; - userData.isRGBE = true; + userData.isRGBE = converted.isRGBE; } else if (extName === '.hdr' || extName === '.exr') { const source = asset.source; const converted = await convertHDROrEXR(extName, source, asset.uuid, asset.temp); @@ -93,8 +92,7 @@ export const ImageHandler: AssetHandler = { imageDataBufferOrimagePath = converted.source; // 对于 rgbe 类型图片默认关闭这个选项 userData.fixAlphaTransparencyArtifacts = false; - // hdr 导入的,默认钩上 isRGBE - userData.isRGBE = true; + userData.isRGBE = converted.isRGBE; const sharpResult = await Sharp(imageDataBufferOrimagePath); const metaData = await sharpResult.metadata(); // 长宽符合 cubemap 的导入规则时,默认导入成 texture cube diff --git a/src/core/assets/asset-handler/assets/image/utils.ts b/src/core/assets/asset-handler/assets/image/utils.ts index 986ac682a..f95db42aa 100644 --- a/src/core/assets/asset-handler/assets/image/utils.ts +++ b/src/core/assets/asset-handler/assets/image/utils.ts @@ -116,7 +116,6 @@ export async function handleImageUserData(asset: Asset | VirtualAsset, imageData const userData = asset.userData as ImageAssetUserData; const sharpResult = Sharp(imageDataBufferOrimagePath); const metaData = await sharpResult.metadata(); - userData.hasAlpha = metaData.hasAlpha; userData.type ||= 'texture'; // Do flip if needed. const flipVertical = !!userData.flipVertical; @@ -188,9 +187,28 @@ export async function handleImageUserData(asset: Asset | VirtualAsset, imageData const opts = { raw: { width: width!, height: height!, channels: channels! } }; imageDataBufferOrimagePath = await Sharp(buffer, opts).toFormat('png').toBuffer(); } + userData.hasAlpha = await hasMeaningfulAlpha(imageDataBufferOrimagePath, !!userData.isRGBE); return imageDataBufferOrimagePath; } +async function hasMeaningfulAlpha(imageData: Buffer, isRGBE: boolean) { + const sharpResult = Sharp(imageData); + const metaData = await sharpResult.metadata(); + if (!metaData.hasAlpha) { + return false; + } + if (isRGBE) { + return true; + } + const pixelData = await sharpResult.ensureAlpha().raw().toBuffer(); + for (let index = Color.Alpha; index < pixelData.length; index += RGBAChannels) { + if (pixelData[index] !== 255) { + return true; + } + } + return false; +} + export async function importWithType(asset: Asset | VirtualAsset, type: ImageImportType, displayName: string, extName: string) { const userData = asset.userData; switch (type) { diff --git a/src/core/assets/asset-handler/assets/instantiation-asset/asset.ts b/src/core/assets/asset-handler/assets/instantiation-asset/asset.ts index b5091b047..b701cc5e2 100644 --- a/src/core/assets/asset-handler/assets/instantiation-asset/asset.ts +++ b/src/core/assets/asset-handler/assets/instantiation-asset/asset.ts @@ -5,7 +5,7 @@ import { AssetHandlerBase } from '../../../@types/protected'; import { createReadStream, createWriteStream, ensureDirSync, existsSync, readdirSync, removeSync } from 'fs-extra'; import { dirname, join, parse } from 'path'; import utils from '../../../../base/utils'; -import { GlobalPaths } from '../../../../../global'; +import { resolveUnzipTool } from '../utils/unzip'; export const InstantiationAssetHandler: AssetHandlerBase = { // Handler 的名字,用于指定 Handler as 等 @@ -25,7 +25,7 @@ export const InstantiationAssetHandler: AssetHandlerBase = { async import(asset: Asset) { const temp = join(asset._assetDB.options.temp, asset.uuid); - const uzipTool = process.platform === 'darwin' ? 'unzip' : join(GlobalPaths.staticDir, 'tools/unzip.exe'); + const uzipTool = resolveUnzipTool(); await utils.Process.quickSpawn(uzipTool, [asset.source, '-d', temp]); diff --git a/src/core/assets/asset-handler/assets/utils/cmft.ts b/src/core/assets/asset-handler/assets/utils/cmft.ts new file mode 100644 index 000000000..3b609cfa8 --- /dev/null +++ b/src/core/assets/asset-handler/assets/utils/cmft.ts @@ -0,0 +1,35 @@ +'use strict'; + +import { join } from 'path'; +import { GlobalPaths } from '../../../../../global'; +import { resolveExecutable } from './tool-resolver'; + +export function resolveCmftTool(version = ''): string { + const versions = version ? [version, ''] : ['']; + const fileCandidates: string[] = []; + + for (const currentVersion of versions) { + if (process.platform === 'win32') { + fileCandidates.push(join(GlobalPaths.staticDir, `tools/cmft/cmftRelease64${currentVersion}.exe`)); + } else { + fileCandidates.push( + join(GlobalPaths.staticDir, `tools/cmft/cmftRelease64${currentVersion}`), + join(GlobalPaths.staticDir, `tools/cmft/linux/cmftRelease64${currentVersion}`), + ); + } + } + + const tool = resolveExecutable({ + fileCandidates, + }); + + if (tool) { + return tool; + } + + throw new Error( + process.platform === 'linux' + ? 'Unable to locate cmft. Place the Linux binary under `static/tools/cmft/` (for example `static/tools/cmft/linux/cmftRelease64`).' + : 'Unable to locate cmft.', + ); +} diff --git a/src/core/assets/asset-handler/assets/utils/fbx-converter.ts b/src/core/assets/asset-handler/assets/utils/fbx-converter.ts index 0118f992d..ba70d925a 100644 --- a/src/core/assets/asset-handler/assets/utils/fbx-converter.ts +++ b/src/core/assets/asset-handler/assets/utils/fbx-converter.ts @@ -5,6 +5,32 @@ import fs, { pathExists } from 'fs-extra'; import cp from 'child_process'; import { i18nTranslate, linkToAssetTarget } from '../../utils'; import { I18nKeys } from '../../../../../i18n/types/generated'; +import { resolveExecutable } from './tool-resolver'; +import { GlobalPaths } from '../../../../../global'; + +export function hasFbxGlTfConvTool() { + return Boolean(resolveFbxGlTfConvTool()); +} + +function resolveFbxGlTfConvTool() { + const fileCandidates = + process.platform === 'linux' + ? [ + ps.join(GlobalPaths.staticDir, 'tools', 'FBX-glTF-conv', 'FBX-glTF-conv'), + ps.join(GlobalPaths.staticDir, 'tools', 'FBX-glTF-conv', 'linux', 'FBX-glTF-conv'), + ] + : (() => { + const packageJsonPath = require.resolve('@cocos/fbx-gltf-conv/package.json'); + const packageDir = ps.dirname(packageJsonPath); + return process.platform === 'win32' + ? [ps.join(packageDir, 'bin', 'win32', 'FBX-glTF-conv.exe')] + : [ps.join(packageDir, 'bin', 'darwin', 'FBX-glTF-conv')]; + })(); + + return resolveExecutable({ + fileCandidates, + }); +} export function createFbxConverter(options: { unitConversion?: 'geometry-level' | 'hierarchy-level' | 'disabled'; @@ -14,7 +40,15 @@ export function createFbxConverter(options: { matchMeshNames?: boolean; }): IAbstractConverter { const outFileName = 'out.gltf'; - let { tool: toolPath } = require('@cocos/fbx-gltf-conv'); + let toolPath = resolveFbxGlTfConvTool(); + + if (!toolPath) { + throw new Error( + process.platform === 'linux' + ? 'Unable to locate FBX-glTF-conv. Place the Linux binary under `static/tools/FBX-glTF-conv/` or provide `static/tools/FBX2glTF/FBX2glTF` so Linux can fall back to FBX2glTF.' + : 'Unable to locate FBX-glTF-conv.', + ); + } const temp = toolPath.replace('app.asar', 'app.asar.unpacked'); if (fs.existsSync(temp)) { @@ -34,7 +68,7 @@ export function createFbxConverter(options: { const cliArgs: string[] = []; // - cliArgs.push(quotPathArg(asset.source)); + cliArgs.push(asset.source); // --unit-conversion cliArgs.push('--unit-conversion', options.unitConversion ?? 'geometry-level'); @@ -56,17 +90,17 @@ export function createFbxConverter(options: { // --out const outFile = ps.join(outputDir, outFileName); await fs.ensureDir(ps.dirname(outFile)); - cliArgs.push('--out', quotPathArg(outFile)); + cliArgs.push('--out', outFile); // --fbm-dir const fbmDir = ps.join(outputDir, '.fbm'); await fs.ensureDir(fbmDir); - cliArgs.push('--fbm-dir', quotPathArg(fbmDir)); + cliArgs.push('--fbm-dir', fbmDir); // --log-file const logFile = getLogFile(outputDir); await fs.ensureDir(ps.dirname(logFile)); - cliArgs.push('--log-file', quotPathArg(logFile)); + cliArgs.push('--log-file', logFile); let callOk = await callFbxGLTFConv(toolPath, cliArgs, outputDir); if (callOk && !(await pathExists(outFile))) { @@ -101,15 +135,10 @@ export function createFbxConverter(options: { }, }; - function quotPathArg(p: string) { - return `"${p}"`; - } - function callFbxGLTFConv(tool: string, args: string[], cwd: string) { return new Promise((resolve, reject) => { - const child = cp.spawn(quotPathArg(tool), args, { + const child = cp.spawn(tool, args, { cwd, - shell: true, }); let output = ''; diff --git a/src/core/assets/asset-handler/assets/utils/fbx2glTf.ts b/src/core/assets/asset-handler/assets/utils/fbx2glTf.ts index 9721adc27..05cbf09aa 100644 --- a/src/core/assets/asset-handler/assets/utils/fbx2glTf.ts +++ b/src/core/assets/asset-handler/assets/utils/fbx2glTf.ts @@ -9,6 +9,12 @@ import os from 'os'; import path from 'path'; import rimraf from 'rimraf'; import { i18nTranslate } from '../../utils'; +import { resolveExecutable } from './tool-resolver'; +import { GlobalPaths } from '../../../../../global'; + +export function hasFbx2GltfTool() { + return Boolean(resolveFbx2GltfTool()); +} /** * Converts an FBX to a GTLF or GLB file. @@ -22,18 +28,14 @@ import { i18nTranslate } from '../../utils'; export function convert(srcFile: string, destFile: string, opts: string[] = []) { return new Promise((resolve, reject) => { try { - const fbx2gltfRoot = path.dirname(require.resolve('@cocos/fbx2gltf')); - - const binExt = os.type() === 'Windows_NT' ? '.exe' : ''; - let tool = path.join(fbx2gltfRoot, 'bin', os.type(), 'FBX2glTF' + binExt); + const tool = resolveFbx2GltfTool(); - const temp = tool.replace('app.asar', 'app.asar.unpacked'); - if (fs.existsSync(temp)) { - tool = temp; - } - - if (!fs.existsSync(tool)) { - throw new Error(`Unsupported OS: ${os.type()}`); + if (!tool) { + throw new Error( + os.type() === 'Linux' + ? 'Unable to locate FBX2glTF. Place the Linux binary at `static/tools/FBX2glTF/FBX2glTF`.' + : 'Unable to locate FBX2glTF.', + ); } let destExt = ''; @@ -101,3 +103,26 @@ export function convert(srcFile: string, destFile: string, opts: string[] = []) } }); } + +function resolveFbx2GltfTool() { + const binExt = os.type() === 'Windows_NT' ? '.exe' : ''; + const fileCandidates = [ + path.join(GlobalPaths.staticDir, 'tools', 'FBX2glTF', 'FBX2glTF' + binExt), + ]; + + if (os.type() === 'Linux') { + fileCandidates.push(path.join(GlobalPaths.staticDir, 'tools', 'FBX2glTF', 'linux', 'FBX2glTF')); + } else { + const fbx2gltfRoot = path.dirname(require.resolve('@cocos/fbx2gltf')); + let tool = path.join(fbx2gltfRoot, 'bin', os.type(), 'FBX2glTF' + binExt); + const temp = tool.replace('app.asar', 'app.asar.unpacked'); + if (fs.existsSync(temp)) { + tool = temp; + } + fileCandidates.push(tool); + } + + return resolveExecutable({ + fileCandidates, + }); +} diff --git a/src/core/assets/asset-handler/assets/utils/tool-resolver.ts b/src/core/assets/asset-handler/assets/utils/tool-resolver.ts new file mode 100644 index 000000000..5e512ca2f --- /dev/null +++ b/src/core/assets/asset-handler/assets/utils/tool-resolver.ts @@ -0,0 +1,63 @@ +'use strict'; + +import { closeSync, constants, openSync, readSync } from 'fs'; +import { existsSync, statSync } from 'fs-extra'; + +interface IResolveExecutableOptions { + fileCandidates?: string[]; +} + +export function resolveExecutable(options: IResolveExecutableOptions): string | undefined { + for (const fileCandidate of options.fileCandidates ?? []) { + if (isExecutableFile(fileCandidate)) { + return fileCandidate; + } + } + + return undefined; +} + +function isExecutableFile(filePath: string) { + if (!existsSync(filePath)) { + return false; + } + + try { + const stats = statSync(filePath); + if (!stats.isFile()) { + return false; + } + + if (process.platform === 'win32') { + return true; + } + + if (!(stats.mode & (constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH))) { + return false; + } + + return !isWindowsPortableExecutable(filePath); + } catch { + return false; + } +} + +function isWindowsPortableExecutable(filePath: string) { + if (process.platform !== 'linux') { + return false; + } + + let fd: number | undefined; + try { + fd = openSync(filePath, 'r'); + const header = Buffer.alloc(2); + const bytesRead = readSync(fd, header, 0, header.length, 0); + return bytesRead === 2 && header[0] === 0x4d && header[1] === 0x5a; + } catch { + return false; + } finally { + if (typeof fd === 'number') { + closeSync(fd); + } + } +} diff --git a/src/core/assets/asset-handler/assets/utils/unzip.ts b/src/core/assets/asset-handler/assets/utils/unzip.ts new file mode 100644 index 000000000..28b21f6b0 --- /dev/null +++ b/src/core/assets/asset-handler/assets/utils/unzip.ts @@ -0,0 +1,31 @@ +'use strict'; + +import { join } from 'path'; +import { GlobalPaths } from '../../../../../global'; +import { resolveExecutable } from './tool-resolver'; + +export function resolveUnzipTool(): string { + const fileCandidates = process.platform === 'win32' + ? [ + join(GlobalPaths.staticDir, 'tools', 'unzip.exe'), + join(GlobalPaths.staticDir, 'tools', 'unzip.exe', 'unzip.exe'), + ] + : [ + join(GlobalPaths.staticDir, 'tools', 'unzip'), + join(GlobalPaths.staticDir, 'tools', 'unzip', 'bin', 'unzip'), + ]; + + const tool = resolveExecutable({ + fileCandidates, + }); + + if (tool) { + return tool; + } + + throw new Error( + process.platform === 'win32' + ? 'Unable to locate unzip.exe. Place it under `static/tools/unzip.exe/`.' + : 'Unable to locate unzip. Place the Linux binary under `static/tools/unzip/`.', + ); +} diff --git a/src/core/assets/asset-handler/assets/utils/uv-unwrap.ts b/src/core/assets/asset-handler/assets/utils/uv-unwrap.ts index 1f8fa5d32..868d11f5d 100644 --- a/src/core/assets/asset-handler/assets/utils/uv-unwrap.ts +++ b/src/core/assets/asset-handler/assets/utils/uv-unwrap.ts @@ -2,6 +2,7 @@ import os from 'os'; import path from 'path'; import utils from '../../../../base/utils'; import { GlobalPaths } from '../../../../../global'; +import { resolveExecutable } from './tool-resolver'; /** * * @param inputFile The file is the mesh data extracted from cc.Mesh for generating LightmapUV. @@ -10,11 +11,23 @@ import { GlobalPaths } from '../../../../../global'; export function unwrapLightmapUV(inputFile: string, outFile: string) { const toolName = 'uvunwrap'; const toolExt = os.type() === 'Windows_NT' ? '.exe' : ''; - // @ts-ignore - const tool = path.join(GlobalPaths.staticDir, 'tools/LightFX', toolName + toolExt); + const tool = resolveExecutable({ + fileCandidates: [ + path.join(GlobalPaths.staticDir, 'tools/LightFX', toolName + toolExt), + path.join(GlobalPaths.staticDir, 'tools/LightFX', toolName), + path.join(GlobalPaths.staticDir, 'tools', 'uvunwrap', toolName + toolExt), + path.join(GlobalPaths.staticDir, 'tools', 'uvunwrap', toolName), + ], + }); + + if (!tool) { + throw new Error( + os.type() === 'Linux' + ? 'Unable to locate uvunwrap. Place the Linux binary under `static/tools/uvunwrap/` or `static/tools/LightFX/`.' + : 'Unable to locate uvunwrap.', + ); + } const args = ['--input', inputFile, '--output', outFile]; - return utils.Process.quickSpawn(tool, args, { - shell: true, - }); + return utils.Process.quickSpawn(tool, args); } diff --git a/workflow/download-tools.js b/workflow/download-tools.js index 0c08937e5..1ce6ceb00 100644 --- a/workflow/download-tools.js +++ b/workflow/download-tools.js @@ -1,15 +1,16 @@ const fs = require('fs'); const path = require('path'); -const https = require('https'); const http = require('http'); -const { execSync } = require('child_process'); +const https = require('https'); +const { execFileSync } = require('child_process'); -// 工具配置 const tools = { win32: [ { url: 'http://download.cocos.com/CocosSDK/tools/unzip.exe', dist: 'unzip.exe', + fileName: 'unzip.exe', + outputs: ['unzip.exe'], }, { url: 'http://download.cocos.com/CocosSDK/tools/PVRTexToolCLI_win32_20251028.zip', @@ -55,11 +56,6 @@ const tools = { url: 'http://download.cocos.com/CocosSDK/tools/cmake-3.24.3-windows-x86_64.zip', dist: 'cmake', }, - // 注意:windows-process-tree 的 URL 可能已失效,暂时注释 - // { - // url: 'http://ftp.cocos.org/TestBuilds/Editor-3d/npm/windows-process-tree-0.6.0-28.0.0_win32.zip', - // dist: 'windows-process-tree', - // } ], darwin: [ { @@ -100,8 +96,26 @@ const tools = { }, { url: 'http://download.cocos.com/CocosSDK/tools/process-info-20231116-darwin.zip', - dist: 'process-info' - } + dist: 'process-info', + }, + ], + linux: [ + { + url: 'https://github.com/dariomanesku/cmft-bin/raw/master/cmft_lin64.zip', + dist: 'cmft', + outputs: ['linux/cmftRelease64'], + moves: [ + { from: 'cmft', to: 'linux/cmftRelease64' }, + ], + executables: ['linux/cmftRelease64'], + }, + { + url: 'https://github.com/facebookincubator/FBX2glTF/releases/download/v0.9.7/FBX2glTF-linux-x64', + dist: 'FBX2glTF', + fileName: 'FBX2glTF', + outputs: ['FBX2glTF'], + executables: ['FBX2glTF'], + }, ], common: [ { @@ -115,11 +129,10 @@ const tools = { { url: 'http://download.cocos.com/CocosSDK/tools/debug.keystore-201112.zip', dist: 'keystore', - } - ] + }, + ], }; -// 工具类 class ToolDownloader { constructor() { this.scriptDir = __dirname; @@ -129,44 +142,69 @@ class ToolDownloader { this.platform = process.platform; } - // 确保目录存在 + resolveUnzipTool() { + if (this.platform === 'win32') { + return null; + } + + const candidates = [ + path.join(this.toolsDir, 'unzip'), + path.join(this.toolsDir, 'unzip', 'bin', 'unzip'), + 'unzip', + ]; + + for (const candidate of candidates) { + try { + if (candidate.includes(path.sep)) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + continue; + } + + execFileSync('which', [candidate], { stdio: 'pipe' }); + return candidate; + } catch { + continue; + } + } + + return null; + } + ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); - console.log(`📁 创建目录: ${path.relative(this.projectRoot, dirPath)}`); + console.log(`Create directory: ${path.relative(this.projectRoot, dirPath)}`); } } - // 下载文件(带重试机制) async downloadFile(url, destPath, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { await this._downloadFileSingle(url, destPath); - return; // 成功则退出 + return; } catch (error) { - console.log(`\n⚠️ 下载失败 (尝试 ${attempt}/${retries}): ${error.message}`); + console.log(`\nRetry ${attempt}/${retries} failed: ${error.message}`); - // 清理失败的文件 if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } if (attempt === retries) { - throw error; // 最后一次尝试失败,抛出错误 + throw error; } - // 等待一段时间后重试 const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); - console.log(`⏳ ${delay}ms 后重试...`); + console.log(`Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } - // 单次下载文件 async _downloadFileSingle(url, destPath) { return new Promise((resolve, reject) => { - console.log(`📥 下载: ${url}`); + console.log(`Downloading: ${url}`); const protocol = url.startsWith('https:') ? https : http; const file = fs.createWriteStream(destPath); @@ -181,7 +219,7 @@ class ToolDownloader { downloadedSize += chunk.length; if (totalSize > 0) { const progress = ((downloadedSize / totalSize) * 100).toFixed(1); - process.stdout.write(`\r📥 下载进度: ${progress}% (${this.formatBytes(downloadedSize)}/${this.formatBytes(totalSize)})`); + process.stdout.write(`\rDownloading: ${progress}% (${this.formatBytes(downloadedSize)}/${this.formatBytes(totalSize)})`); } }); @@ -189,11 +227,10 @@ class ToolDownloader { file.on('finish', () => { file.close(); - console.log(`\n✅ 下载完成: ${path.basename(destPath)}`); + console.log(`\nDownload complete: ${path.basename(destPath)}`); resolve(); }); } else if (response.statusCode === 302 || response.statusCode === 301) { - // 处理重定向 file.close(); if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); @@ -204,7 +241,7 @@ class ToolDownloader { if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } - reject(new Error(`文件不存在 (404): ${url}`)); + reject(new Error(`File not found (404): ${url}`)); } else { file.close(); if (fs.existsSync(destPath)) { @@ -214,182 +251,243 @@ class ToolDownloader { } }); - request.on('error', (err) => { + request.on('error', (error) => { file.close(); if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } - reject(err); + reject(error); }); - // 设置超时 request.setTimeout(120000, () => { request.destroy(); - reject(new Error('下载超时 (120秒)')); + reject(new Error('Download timeout (120s)')); }); }); } - // 解压文件 - async extractFile(zipPath, extractDir) { - console.log(`📦 解压: ${path.basename(zipPath)}`); + extractFile(zipPath, extractDir) { + console.log(`Extracting: ${path.basename(zipPath)}`); try { - let command , options = {}; if (this.platform === 'win32') { - // Windows 使用 PowerShell 的 Expand-Archive - command = `powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force"`; + execFileSync( + 'powershell', + ['-Command', `Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force`], + { stdio: 'pipe', maxBuffer: 1024 * 1024 * 50 }, + ); } else { - // macOS/Linux 使用 unzip - command = `unzip -o '${zipPath}' -d '${extractDir}'`; - // 增加缓冲区大小 - options = { - maxBuffer: 1024 * 1024 * 50 // 增加到 50MB,防止解压失败 - }; + const unzipTool = this.resolveUnzipTool(); + if (!unzipTool) { + throw new Error('Missing unzip command'); + } + + execFileSync(unzipTool, ['-o', zipPath, '-d', extractDir], { + stdio: 'pipe', + maxBuffer: 1024 * 1024 * 50, + }); } - execSync(command, { stdio: 'pipe', ...options }); - console.log(`✅ 解压完成: ${path.basename(zipPath)}`); + console.log(`Extract complete: ${path.basename(zipPath)}`); } catch (error) { - throw new Error(`解压失败: ${error.message}`); + throw new Error(`Extract failed: ${error.message}`); } } - // 格式化字节数 formatBytes(bytes) { - if (bytes === 0) return '0 B'; + if (bytes === 0) { + return '0 B'; + } + const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } - // 检查解压工具是否可用 checkExtractTools() { try { if (this.platform === 'win32') { - execSync('powershell -Command "Get-Command Expand-Archive"', { stdio: 'pipe' }); - } else { - execSync('which unzip', { stdio: 'pipe' }); + execFileSync('powershell', ['-Command', 'Get-Command Expand-Archive'], { stdio: 'pipe' }); + return true; } - return true; + + return Boolean(this.resolveUnzipTool()); } catch { return false; } } - // 检查文件是否需要解压 isArchiveFile(filePath) { const ext = path.extname(filePath).toLowerCase(); return ['.zip', '.tar', '.gz', '.7z', '.rar'].includes(ext); } - // 复制文件到目标目录 - async copyFile(sourcePath, targetDir) { - console.log(`📋 复制: ${path.basename(sourcePath)}`); + getToolRoot(tool) { + return path.join(this.toolsDir, tool.dist); + } - try { - const fileName = path.basename(sourcePath); - const targetPath = path.join(targetDir, fileName); + getExpectedOutputs(tool) { + const toolRoot = this.getToolRoot(tool); + if (Array.isArray(tool.outputs) && tool.outputs.length > 0) { + return tool.outputs.map(output => path.join(toolRoot, output)); + } - // 确保目标目录存在 - this.ensureDir(targetDir); + return [toolRoot]; + } - // 复制文件 - fs.copyFileSync(sourcePath, targetPath); - console.log(`✅ 复制完成: ${fileName}`); - } catch (error) { - throw new Error(`复制失败: ${error.message}`); + isExecutable(filePath) { + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return false; + } + + if (this.platform === 'win32') { + return true; } + + return Boolean(fs.statSync(filePath).mode & 0o111); + } + + isToolInstalled(tool) { + if (!this.getExpectedOutputs(tool).every(output => fs.existsSync(output))) { + return false; + } + + for (const executable of tool.executables || []) { + if (!this.isExecutable(path.join(this.getToolRoot(tool), executable))) { + return false; + } + } + + return true; + } + + copyFile(sourcePath, targetDir, targetName) { + const fileName = targetName || path.basename(sourcePath); + const targetPath = path.join(targetDir, fileName); + + this.ensureDir(targetDir); + fs.copyFileSync(sourcePath, targetPath); + console.log(`Copied: ${path.relative(this.projectRoot, targetPath)}`); + return targetPath; + } + + applyMoves(tool, targetDir) { + for (const move of tool.moves || []) { + const sourcePath = path.join(targetDir, move.from); + const destPath = path.join(targetDir, move.to); + + if (!fs.existsSync(sourcePath)) { + throw new Error(`Expected extracted file is missing: ${path.relative(this.projectRoot, sourcePath)}`); + } + + this.ensureDir(path.dirname(destPath)); + if (fs.existsSync(destPath)) { + fs.rmSync(destPath, { recursive: true, force: true }); + } + fs.renameSync(sourcePath, destPath); + console.log(`Moved: ${path.relative(this.projectRoot, sourcePath)} -> ${path.relative(this.projectRoot, destPath)}`); + } + } + + ensureExecutables(tool, targetDir) { + if (this.platform === 'win32') { + return; + } + + for (const executable of tool.executables || []) { + const executablePath = path.join(targetDir, executable); + if (!fs.existsSync(executablePath)) { + throw new Error(`Executable is missing: ${path.relative(this.projectRoot, executablePath)}`); + } + + const mode = fs.statSync(executablePath).mode; + fs.chmodSync(executablePath, mode | 0o755); + console.log(`chmod +x ${path.relative(this.projectRoot, executablePath)}`); + } + } + + getTempFileName(tool) { + return path.basename(new URL(tool.url).pathname); } - // 主处理函数 async processTool(tool, index, total) { const progress = `[${index + 1}/${total}]`; - console.log(`\n${progress} 处理: ${tool.dist}`); + const targetDir = this.getToolRoot(tool); + + console.log(`\n${progress} Processing: ${tool.dist}`); try { - // 生成文件路径 - const fileName = path.basename(tool.url); - const tempFilePath = path.join(this.tempDir, fileName); - const targetDir = path.join(this.toolsDir, tool.dist); - - // 检查是否已存在 - if (fs.existsSync(targetDir)) { - console.log(`⏭️ 跳过 ${tool.dist} (已存在)`); + if (this.isToolInstalled(tool)) { + console.log(`Skip ${tool.dist} (already installed)`); return { success: true, skipped: true }; } - // 下载 + const tempFilePath = path.join(this.tempDir, this.getTempFileName(tool)); await this.downloadFile(tool.url, tempFilePath); - // 创建目标目录 this.ensureDir(targetDir); - // 判断是否需要解压 if (this.isArchiveFile(tempFilePath)) { - // 解压文件 - await this.extractFile(tempFilePath, targetDir); + this.extractFile(tempFilePath, targetDir); } else { - // 直接复制文件 - await this.copyFile(tempFilePath, targetDir); + this.copyFile(tempFilePath, targetDir, tool.fileName); } - // 清理临时文件 - fs.unlinkSync(tempFilePath); + this.applyMoves(tool, targetDir); + this.ensureExecutables(tool, targetDir); - console.log(`✅ ${tool.dist} 处理完成`); - return { success: true, skipped: false }; + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + console.log(`${tool.dist} is ready`); + return { success: true, skipped: false }; } catch (error) { - console.error(`❌ ${tool.dist} 处理失败:`, error.message); + console.error(`${tool.dist} failed: ${error.message}`); return { success: false, error: error.message }; } } - // 清理临时目录 cleanupTempDir() { - if (fs.existsSync(this.tempDir)) { - try { - const files = fs.readdirSync(this.tempDir); - if (files.length === 0) { - fs.rmdirSync(this.tempDir); - console.log('🧹 清理临时目录'); - } else { - console.log(`⚠️ 临时目录中还有 ${files.length} 个文件未清理`); - } - } catch (error) { - console.log(`⚠️ 清理临时目录失败: ${error.message}`); + if (!fs.existsSync(this.tempDir)) { + return; + } + + try { + const files = fs.readdirSync(this.tempDir); + if (files.length === 0) { + fs.rmdirSync(this.tempDir); + console.log('Cleaned temporary directory'); + } else { + console.log(`Temporary directory still contains ${files.length} file(s)`); } + } catch (error) { + console.log(`Failed to clean temporary directory: ${error.message}`); } } - // 主函数 async run() { - console.log(`🖥️ 当前平台: ${this.platform}`); + console.log(`Current platform: ${this.platform}`); - // 检查解压工具 if (!this.checkExtractTools()) { - console.error('❌ 缺少解压工具,请安装 unzip (macOS/Linux) 或确保 PowerShell 可用 (Windows)'); + console.error('Missing extract tool. Install unzip on Linux/macOS or make sure PowerShell is available on Windows.'); process.exit(1); } - // 创建目录 this.ensureDir(this.tempDir); this.ensureDir(this.toolsDir); - // 获取工具列表 const platformTools = tools[this.platform] || []; const commonTools = tools.common || []; const allTools = [...platformTools, ...commonTools]; - console.log(`📋 需要下载 ${allTools.length} 个工具文件\n`); + console.log(`Need to process ${allTools.length} tool item(s)\n`); let successCount = 0; let skipCount = 0; let failCount = 0; - // 处理每个工具 for (let i = 0; i < allTools.length; i++) { const result = await this.processTool(allTools[i], i, allTools.length); @@ -404,34 +502,23 @@ class ToolDownloader { } } - // 清理临时目录 this.cleanupTempDir(); - // 显示统计信息 - console.log(`\n🎉 处理完成!`); - console.log(`✅ 成功: ${successCount}`); - console.log(`⏭️ 跳过: ${skipCount}`); - console.log(`❌ 失败: ${failCount}`); + console.log('\nProcess complete'); + console.log(`Success: ${successCount}`); + console.log(`Skipped: ${skipCount}`); + console.log(`Failed: ${failCount}`); if (failCount > 0) { - console.log(`\n💡 提示:`); - console.log(` - 失败的下载可能是网络问题或文件不存在`); - console.log(` - 可以重新运行脚本重试: npm run download-tools`); - console.log(` - 某些工具可能不是必需的,可以继续使用其他功能`); - - // 不强制退出,让用户决定是否继续 - console.log(`\n⚠️ 有 ${failCount} 个工具下载失败,但脚本将继续完成`); - } else { - console.log(`\n🎊 所有工具下载成功!`); + console.log('\nSome downloads failed. You can rerun `npm run download-tools` after fixing network or source issues.'); } } } -// 运行脚本 if (require.main === module) { const downloader = new ToolDownloader(); downloader.run().catch((error) => { - console.error('❌ 脚本执行失败:', error.message); + console.error('download-tools.js failed:', error.message); process.exit(1); }); } diff --git a/workflow/linux-doctor.sh b/workflow/linux-doctor.sh new file mode 100644 index 000000000..8fb1d27ac --- /dev/null +++ b/workflow/linux-doctor.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="check" +STRICT=0 + +for arg in "$@"; do + case "$arg" in + --install-basic) + MODE="install-basic" + ;; + --strict) + STRICT=1 + ;; + -h|--help) + cat <<'EOF' +Usage: + bash workflow/linux-doctor.sh + bash workflow/linux-doctor.sh --install-basic + bash workflow/linux-doctor.sh --strict + +Options: + --install-basic Install packages needed by download-tools.js (currently unzip) + --strict Exit with non-zero code when required tools are missing +EOF + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; + esac +done + +has_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +resolve_static_tool() { + for candidate in "$@"; do + if [[ -x "$candidate" && ! -d "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +static_tools_dir="$repo_root/static/tools" + +install_basic_packages() { + local runner="" + if [[ "$(id -u)" != "0" ]]; then + if has_cmd sudo; then + runner="sudo" + else + echo "Need root or sudo to install packages." >&2 + exit 1 + fi + fi + + if has_cmd dnf; then + $runner dnf install -y unzip + return + fi + + if has_cmd yum; then + $runner yum install -y unzip + return + fi + + if has_cmd apt-get; then + $runner apt-get update + $runner apt-get install -y unzip + return + fi + + echo "Unsupported package manager. Install unzip manually." >&2 + exit 1 +} + +if [[ "$MODE" == "install-basic" ]]; then + install_basic_packages +fi + +errors=0 + +report_ok() { + printf '[OK] %s: %s\n' "$1" "$2" +} + +report_warn() { + printf '[WARN] %s: %s\n' "$1" "$2" +} + +report_err() { + printf '[MISS] %s: %s\n' "$1" "$2" + errors=$((errors + 1)) +} + +echo '== Cocos CLI Linux Doctor ==' +echo 'Runtime discovery only checks files under static/tools.' + +if tool="$(resolve_static_tool "$static_tools_dir/FBX-glTF-conv/FBX-glTF-conv" "$static_tools_dir/FBX-glTF-conv/linux/FBX-glTF-conv")"; then + report_ok "FBX-glTF-conv" "$tool" +else + report_warn "FBX-glTF-conv" 'Not installed; Linux will fall back to FBX2glTF when available' +fi + +if tool="$(resolve_static_tool "$static_tools_dir/FBX2glTF/FBX2glTF" "$static_tools_dir/FBX2glTF/linux/FBX2glTF")"; then + report_ok "FBX2glTF" "$tool" +else + report_err "FBX2glTF" 'Install it at static/tools/FBX2glTF/FBX2glTF' +fi + +if tool="$(resolve_static_tool "$static_tools_dir/cmft/linux/cmftRelease64" "$static_tools_dir/cmft/cmftRelease64")"; then + report_ok "cmft" "$tool" +else + report_warn "cmft" 'Needed only for HDR/EXR and cubemap bake workflows' +fi + +if tool="$(resolve_static_tool "$static_tools_dir/mali_linux/convert")"; then + report_ok "mali convert" "$tool" +else + report_warn "mali convert" 'Needed only for EXR to HDR fallback workflows' +fi + +if tool="$(resolve_static_tool "$static_tools_dir/LightFX/uvunwrap" "$static_tools_dir/uvunwrap/uvunwrap")"; then + report_ok "uvunwrap" "$tool" +else + report_warn "uvunwrap" 'Needed only for lightmap UV unwrap workflows' +fi + +if tool="$(resolve_static_tool "$static_tools_dir/unzip" "$static_tools_dir/unzip/bin/unzip")"; then + report_ok "unzip" "$tool" +else + report_warn "unzip" 'Needed only for instantiation-asset zip import workflows' +fi + +echo +echo 'Recommended static/tools layout:' +echo " $static_tools_dir/FBX2glTF/FBX2glTF" +echo " $static_tools_dir/FBX-glTF-conv/FBX-glTF-conv" +echo " $static_tools_dir/cmft/linux/cmftRelease64" +echo " $static_tools_dir/mali_linux/convert" +echo " $static_tools_dir/uvunwrap/uvunwrap" +echo " $static_tools_dir/unzip" +echo +echo 'download-tools.js may still use the system unzip command during installation.' + +if [[ "$STRICT" == "1" && "$errors" -gt 0 ]]; then + exit 1 +fi From ffacb3ae04ff42b2708baa9086ba2e02c74cba77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8C=E6=95=A6=E4=BC=9F?= Date: Thu, 9 Apr 2026 17:53:30 +0800 Subject: [PATCH 2/3] fix linux tool download bootstrap --- workflow/download-tools.js | 46 +++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/workflow/download-tools.js b/workflow/download-tools.js index 1ce6ceb00..3e3d0ca58 100644 --- a/workflow/download-tools.js +++ b/workflow/download-tools.js @@ -101,18 +101,24 @@ const tools = { ], linux: [ { - url: 'https://github.com/dariomanesku/cmft-bin/raw/master/cmft_lin64.zip', + url: 'http://download.cocos.com/CocosSDK/tools/unzip-linux', + dist: 'unzip', + fileName: 'unzip', + outputs: ['unzip'], + executables: ['unzip'], + }, + { + url: 'http://download.cocos.com/CocosSDK/tools/cmft-linux.zip', dist: 'cmft', - outputs: ['linux/cmftRelease64'], + outputs: ['cmftRelease64'], moves: [ - { from: 'cmft', to: 'linux/cmftRelease64' }, + { from: 'cmft', to: 'cmftRelease64' }, ], - executables: ['linux/cmftRelease64'], + executables: ['cmftRelease64'], }, { - url: 'https://github.com/facebookincubator/FBX2glTF/releases/download/v0.9.7/FBX2glTF-linux-x64', + url: 'http://download.cocos.com/CocosSDK/tools/FBX2glTF-linux.zip', dist: 'FBX2glTF', - fileName: 'FBX2glTF', outputs: ['FBX2glTF'], executables: ['FBX2glTF'], }, @@ -148,15 +154,16 @@ class ToolDownloader { } const candidates = [ + 'unzip', + path.join(this.toolsDir, 'unzip', 'unzip'), path.join(this.toolsDir, 'unzip'), path.join(this.toolsDir, 'unzip', 'bin', 'unzip'), - 'unzip', ]; for (const candidate of candidates) { try { if (candidate.includes(path.sep)) { - if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + if (this.isExecutable(candidate)) { return candidate; } continue; @@ -470,17 +477,30 @@ class ToolDownloader { async run() { console.log(`Current platform: ${this.platform}`); - if (!this.checkExtractTools()) { - console.error('Missing extract tool. Install unzip on Linux/macOS or make sure PowerShell is available on Windows.'); - process.exit(1); - } - this.ensureDir(this.tempDir); this.ensureDir(this.toolsDir); const platformTools = tools[this.platform] || []; const commonTools = tools.common || []; const allTools = [...platformTools, ...commonTools]; + const hasArchiveTools = allTools.some((tool) => this.isArchiveFile(this.getTempFileName(tool))); + + if (this.platform === 'linux' && hasArchiveTools && !this.checkExtractTools()) { + const bootstrapTool = platformTools[0]; + if (bootstrapTool && !this.isArchiveFile(this.getTempFileName(bootstrapTool))) { + console.log(`No extract tool detected. Bootstrapping: ${bootstrapTool.dist}`); + const result = await this.processTool(bootstrapTool, 0, 1); + if (!result.success) { + console.error(`Failed to bootstrap extract tool: ${bootstrapTool.dist}`); + process.exit(1); + } + } + } + + if (hasArchiveTools && !this.checkExtractTools()) { + console.error('Missing extract tool. Install unzip on macOS, make sure PowerShell is available on Windows, or provide the Linux bootstrap unzip from the download server.'); + process.exit(1); + } console.log(`Need to process ${allTools.length} tool item(s)\n`); From 4350171b91c604cbe2c4205c84f593f3558fb193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8C=E6=95=A6=E4=BC=9F?= Date: Thu, 9 Apr 2026 18:01:07 +0800 Subject: [PATCH 3/3] fix linux unzip resolution --- .../asset-handler/assets/utils/unzip.ts | 3 +- workflow/linux-doctor.sh | 155 ------------------ 2 files changed, 2 insertions(+), 156 deletions(-) delete mode 100644 workflow/linux-doctor.sh diff --git a/src/core/assets/asset-handler/assets/utils/unzip.ts b/src/core/assets/asset-handler/assets/utils/unzip.ts index 28b21f6b0..b9a9cc4ea 100644 --- a/src/core/assets/asset-handler/assets/utils/unzip.ts +++ b/src/core/assets/asset-handler/assets/utils/unzip.ts @@ -11,6 +11,7 @@ export function resolveUnzipTool(): string { join(GlobalPaths.staticDir, 'tools', 'unzip.exe', 'unzip.exe'), ] : [ + join(GlobalPaths.staticDir, 'tools', 'unzip', 'unzip'), join(GlobalPaths.staticDir, 'tools', 'unzip'), join(GlobalPaths.staticDir, 'tools', 'unzip', 'bin', 'unzip'), ]; @@ -26,6 +27,6 @@ export function resolveUnzipTool(): string { throw new Error( process.platform === 'win32' ? 'Unable to locate unzip.exe. Place it under `static/tools/unzip.exe/`.' - : 'Unable to locate unzip. Place the Linux binary under `static/tools/unzip/`.', + : 'Unable to locate unzip. Place the Linux binary under `static/tools/unzip/` (for example `static/tools/unzip/unzip`).', ); } diff --git a/workflow/linux-doctor.sh b/workflow/linux-doctor.sh deleted file mode 100644 index 8fb1d27ac..000000000 --- a/workflow/linux-doctor.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -MODE="check" -STRICT=0 - -for arg in "$@"; do - case "$arg" in - --install-basic) - MODE="install-basic" - ;; - --strict) - STRICT=1 - ;; - -h|--help) - cat <<'EOF' -Usage: - bash workflow/linux-doctor.sh - bash workflow/linux-doctor.sh --install-basic - bash workflow/linux-doctor.sh --strict - -Options: - --install-basic Install packages needed by download-tools.js (currently unzip) - --strict Exit with non-zero code when required tools are missing -EOF - exit 0 - ;; - *) - echo "Unknown option: $arg" >&2 - exit 1 - ;; - esac -done - -has_cmd() { - command -v "$1" >/dev/null 2>&1 -} - -resolve_static_tool() { - for candidate in "$@"; do - if [[ -x "$candidate" && ! -d "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - - return 1 -} - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -static_tools_dir="$repo_root/static/tools" - -install_basic_packages() { - local runner="" - if [[ "$(id -u)" != "0" ]]; then - if has_cmd sudo; then - runner="sudo" - else - echo "Need root or sudo to install packages." >&2 - exit 1 - fi - fi - - if has_cmd dnf; then - $runner dnf install -y unzip - return - fi - - if has_cmd yum; then - $runner yum install -y unzip - return - fi - - if has_cmd apt-get; then - $runner apt-get update - $runner apt-get install -y unzip - return - fi - - echo "Unsupported package manager. Install unzip manually." >&2 - exit 1 -} - -if [[ "$MODE" == "install-basic" ]]; then - install_basic_packages -fi - -errors=0 - -report_ok() { - printf '[OK] %s: %s\n' "$1" "$2" -} - -report_warn() { - printf '[WARN] %s: %s\n' "$1" "$2" -} - -report_err() { - printf '[MISS] %s: %s\n' "$1" "$2" - errors=$((errors + 1)) -} - -echo '== Cocos CLI Linux Doctor ==' -echo 'Runtime discovery only checks files under static/tools.' - -if tool="$(resolve_static_tool "$static_tools_dir/FBX-glTF-conv/FBX-glTF-conv" "$static_tools_dir/FBX-glTF-conv/linux/FBX-glTF-conv")"; then - report_ok "FBX-glTF-conv" "$tool" -else - report_warn "FBX-glTF-conv" 'Not installed; Linux will fall back to FBX2glTF when available' -fi - -if tool="$(resolve_static_tool "$static_tools_dir/FBX2glTF/FBX2glTF" "$static_tools_dir/FBX2glTF/linux/FBX2glTF")"; then - report_ok "FBX2glTF" "$tool" -else - report_err "FBX2glTF" 'Install it at static/tools/FBX2glTF/FBX2glTF' -fi - -if tool="$(resolve_static_tool "$static_tools_dir/cmft/linux/cmftRelease64" "$static_tools_dir/cmft/cmftRelease64")"; then - report_ok "cmft" "$tool" -else - report_warn "cmft" 'Needed only for HDR/EXR and cubemap bake workflows' -fi - -if tool="$(resolve_static_tool "$static_tools_dir/mali_linux/convert")"; then - report_ok "mali convert" "$tool" -else - report_warn "mali convert" 'Needed only for EXR to HDR fallback workflows' -fi - -if tool="$(resolve_static_tool "$static_tools_dir/LightFX/uvunwrap" "$static_tools_dir/uvunwrap/uvunwrap")"; then - report_ok "uvunwrap" "$tool" -else - report_warn "uvunwrap" 'Needed only for lightmap UV unwrap workflows' -fi - -if tool="$(resolve_static_tool "$static_tools_dir/unzip" "$static_tools_dir/unzip/bin/unzip")"; then - report_ok "unzip" "$tool" -else - report_warn "unzip" 'Needed only for instantiation-asset zip import workflows' -fi - -echo -echo 'Recommended static/tools layout:' -echo " $static_tools_dir/FBX2glTF/FBX2glTF" -echo " $static_tools_dir/FBX-glTF-conv/FBX-glTF-conv" -echo " $static_tools_dir/cmft/linux/cmftRelease64" -echo " $static_tools_dir/mali_linux/convert" -echo " $static_tools_dir/uvunwrap/uvunwrap" -echo " $static_tools_dir/unzip" -echo -echo 'download-tools.js may still use the system unzip command during installation.' - -if [[ "$STRICT" == "1" && "$errors" -gt 0 ]]; then - exit 1 -fi