/** * @file Package.ts * @description 场景打包解包 * @create 2024-07-31 * @update 2025-02-14 * @version 5.0.0 */ import { Mesh, Group, Bone, Object3D, Texture } from "three"; import JSZip from "jszip"; import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js'; import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant"; import { unzip, zip, fetchController } from "@/utils"; import { PackageSkeleton } from "@/core/loader/Package.Skeleton"; import { useDispatchSignal } from "@/hooks"; import { ObjectLoader } from './ObjectLoader'; import App from "@/core/app/App"; import type Viewer from "@/core/viewer/Viewer"; interface IPackConfig { // 首包名称 name: string; // 拆分的最深层级 0:拆分至最深层 layer?: number; // 压缩包上传接口函数,多压缩包 zipUploadFun: (zip: File) => Promise; // 原始包上传接口函数(不压缩) rawUploadFun?: (raw: { name: string; files: Record }) => Promise; // 压缩进度回调(0-100) onZipProgress?: (info: { name: string; progress: number }) => void; // 图片处理并发 imageProcessLimit?: number; // 打包进度回调 onProgress?: (progress: number) => void; // 打包完成回调 onComplete?: (_: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => void; } interface IOfflinePackConfig { // 离线包名称(作为首包 zip 名称) name: string; // 拆分的最深层级;0 表示拆分到最深层 layer?: number; // 导出模式:single 为单 zip,multiple 为多 zip mode?: "single" | "multiple"; // 单包导出时的包名(不含后缀) bundleName?: string; // 压缩进度回调(0-100) onZipProgress?: (info: { name: string; progress: number }) => void; // 图片处理并发 imageProcessLimit?: number; // 进度回调 onProgress?: (progress: number) => void; // 完成回调 onComplete?: (result: OfflinePackResult) => void; } interface OfflinePackResult { mode: "single" | "multiple"; // 首包名称(含 .astral) entry: string; // 导出的包文件列表 files: File[]; // 总大小 totalSize: number; // 包数量(首包 + 组包) totalZipNumber: number; } interface IUnpackConfig { // 首包url url: string; // 场景首包加载完成回调 onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void; // 场景加载进度回调 onProgress?: (progress: number) => void; // 场景加载完成回调 onComplete?: () => void; // 离线多包文件列表(可选) offlineFiles?: File[]; // 离线单包(内部包含多个包) offlineBundle?: Blob | ArrayBuffer | Uint8Array; // 离线首包名称(可选,未提供则使用 url 的文件名) offlineEntry?: string; } interface SourceData { name: string; json?: string | ArrayBuffer texture?: string | ArrayBuffer; geometry?: string; drawing?: string; } interface GroupJson { images: any[]; geometries: any[]; object: { children: any[], //groupChildren: string[] }; } export class Package { protected viewer:Viewer; // 控制fetch并发 static _fetch = fetchController(10, false); private totalSize: number = 0; // 总包体大小 private geometryArr: any[]; private imagesArr: any[]; private materialsArr: any[]; private textureArr: any[]; private skeletonsArr: any[]; // 解压时 对应文件夹前缀url private prefix_url: string; private loader: ObjectLoader; private geometryMap: Map; private imagesMap: Map; private materialsMap: Map; private textureMap: Map; private callFunNum: { value: number; }; private skeletonClass: PackageSkeleton; private dataComponentMap: Record | null = null; // 离线包相关属性 // @ts-ignore private offlineZipMap: Map | null = null; // @ts-ignore private offlineEntryZip: string | null = null; // @ts-ignore private offlineFlatPackages: Map> | null = null; // @ts-ignore private offlineFlatEntryBase: string | null = null; private readonly offlinePackagesPrefix = "Packages/"; // @ts-ignore private isOfflineImportContext = false; constructor(viewer:Viewer) { this.viewer = viewer; // 存储已参与打包过的geometry uuid this.geometryArr = []; // 存储已参与打包过的images uuid this.imagesArr = []; // 存储已参与打包过的materials uuid this.materialsArr = []; // 存储已参与打包过的texture uuid this.textureArr = []; // 存储已参与打包过的skeleton uuid this.skeletonsArr = []; /** 下面都是解包用 */ this.prefix_url = ""; this.loader = new ObjectLoader(); this.geometryMap = new Map(); this.imagesMap = new Map(); this.materialsMap = new Map(); this.textureMap = new Map(); this.callFunNum = { value: 0 }; this.skeletonClass = new PackageSkeleton(); } /* -------------------------------------------- 切片打包 --------------------------------------------------- */ /** * 处理 geometry json * @param arr 几何数据数组 */ private handleGeometryJson(arr) { function handler(value) { if (value instanceof Array) { return zip(value, false); } return value; } return arr.map((geometry) => { if (geometry.data) { const data = geometry.data; if (data.attributes) { for (let key in data.attributes) { data.attributes[key].array = handler(data.attributes[key].array); } } if (data.index) { data.index.array = handler(data.index.array); } } return geometry; }) } /** * 处理 image json * @param imageJson * @param zipData 存储待压缩数据 * @returns {string} 返回贴图存储文件名称 */ handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string { if (imageJson.url && typeof imageJson.url === "object" && !imageJson.url.type) { const ktx2Data = (imageJson.url as any).ktx2OriginalData; if (ktx2Data) { const name = imageJson.uuid + `.ktx2`; zipData.push({ name, texture: ktx2Data }); return name; } } if (typeof imageJson.url === "string") { const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`; zipData.push({ name, texture: imageJson.url }); return name; } // 20250707:three的toJSON方法暂不支持KTX2纹理,会返回{url:{},uuid:"xxxxx"} if (!imageJson.url.type) { const name = imageJson.uuid + `.ktx2`; zipData.push({ name, texture: JSON.stringify(imageJson.url) }); return name; } const name = `${imageJson.url.type}!${imageJson.url.width}!${imageJson.url.height}!${imageJson.uuid}.env`; const buffer = new TYPED_ARRAYS[imageJson.url.type](imageJson.url.data); zipData.push({ name, texture: buffer.buffer }); return name; } private findTextureByImageUuid(mesh: Mesh, imageUuid: string): Texture | null { if (!mesh.material) return null; const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) { for (const key in material) { const value = (material as any)[key]; if (value && value.isTexture && value.source?.uuid === imageUuid) { return value as Texture; } } } return null; } /** * 处理 mesh json * @param mesh * @param json group json * @param zipData 存储待压缩数据 */ handleMesh(mesh: Mesh, json:ITHREEScene.SceneJSON, zipData:SourceData[]) { const meshJson: any = mesh.toJSON() as unknown as ITHREEScene.MeshJSON; // 处理几何数据 if (meshJson.geometries) { const geometries: any = []; meshJson.geometries.forEach((geometry) => { if (this.geometryArr.indexOf(geometry.uuid) === -1) { this.geometryArr.push(geometry.uuid); geometries.push(geometry); } }) !json.geometries && (json.geometries = []); json.geometries.push(...this.handleGeometryJson(geometries)); } // 处理贴图image if (meshJson.images) { meshJson.images.forEach((image) => { if (this.imagesArr.indexOf(image.uuid) === -1) { this.imagesArr.push(image.uuid); !json.images && (json.images = []); const texture = this.findTextureByImageUuid(mesh, image.uuid); const textureMeta = (texture as any)?.metadata; if (textureMeta?.isKTX2 && textureMeta?.ktx2OriginalData && typeof image.url === "object") { (image.url as any).ktx2OriginalData = textureMeta.ktx2OriginalData; } const name = this.handleImage(image, zipData) if(name){ json.images.push(name); } } }) } // 处理贴图texture if (meshJson.textures) { meshJson.textures.forEach((texture) => { if (this.textureArr.indexOf(texture.uuid) === -1) { this.textureArr.push(texture.uuid); !json.textures && (json.textures = []); json.textures.push(texture); } }) } // 处理材质material if (meshJson.materials) { meshJson.materials.forEach((material) => { if (this.materialsArr.indexOf(material.uuid) === -1) { this.materialsArr.push(material.uuid); !json.materials && (json.materials = []); json.materials.push(material); } }) } // 处理骨骼动画 if (meshJson.skeletons) { meshJson.skeletons.forEach((skeleton) => { if (this.skeletonsArr.indexOf(skeleton.uuid) === -1) { this.skeletonsArr.push(skeleton.uuid); !json.skeletons && (json.skeletons = []); json.skeletons.push(skeleton); } }) } // 处理动画 if (meshJson.animations) { !json.animations && (json.animations = []); json.animations.push(...meshJson.animations); } // object 字段存入group json(parent json) if (meshJson.object) { !json.object.children && (json.object.children = []); json.object.children.push(meshJson.object); } } private sanitizeDataComponentList(list: any) { if (!Array.isArray(list) || list.length === 0) return null; return list.map((entry) => { if (!entry || typeof entry !== "object") return entry; const config = entry.config && typeof entry.config === "object" ? { ...entry.config } : entry.config; return { ...entry, config, data: null }; }); } private collectDataComponentMap(root: Object3D) { const map: Record = {}; const condition = (obj: any) => !obj.ignore; const visit = (obj: any) => { const sanitized = this.sanitizeDataComponentList(obj?.dataComponent); if (sanitized) { map[obj.uuid] = sanitized; } }; if (typeof (root as any).traverseByCondition === "function") { (root as any).traverseByCondition(visit, condition); } else { root.traverse((obj: any) => { if (!condition(obj)) return; visit(obj); }); } return map; } private applyDataComponentMap(root: Object3D) { const map = this.dataComponentMap; if (!map || Object.keys(map).length === 0) return; const condition = (obj: any) => !obj.ignore; const apply = (obj: any) => { if (!Object.prototype.hasOwnProperty.call(map, obj.uuid)) return; const sanitized = this.sanitizeDataComponentList(map[obj.uuid]); if (sanitized) { obj.dataComponent = sanitized; } }; if (typeof (root as any).traverseByCondition === "function") { (root as any).traverseByCondition(apply, condition); } else { root.traverse((obj: any) => { if (!condition(obj)) return; apply(obj); }); } } /** * 按 group 分组各打包为1个zip文件 * @param {IPackConfig} packConfig * @remarks 首包保存scene基本信息 和 图纸信息 及 基础配置 * @remarks 前面已打包的几何数据和材质贴图不会再次打包 */ async pack(packConfig: IPackConfig) { packConfig.layer = packConfig.layer || 0; this.totalSize = 0; // 首包保存scene基本信息,不clone子级 const newScene = this.viewer.scene.clone(false); newScene.children = []; const sceneJson = newScene.toJSON() as unknown as ITHREEScene.SceneJSON; // scene uuid需要和原来一致,防止绑定在scene的脚本无法还原 sceneJson.object.uuid = this.viewer.scene.uuid; sceneJson.object.children = []; // 20250718: 环境类型是ModelViewer时需要特殊处理,因为scene.toJSON()不会处理renderTargetTexture if(newScene.environment && newScene.environment.isRenderTargetTexture){ sceneJson.object.environmentType = "ModelViewer"; } const sceneZipData: SourceData[] = []; // 处理背景和环境贴图 if (!sceneJson.images) sceneJson.images = []; sceneJson.images = sceneJson.images.map((image) => this.handleImage(image as ITHREEScene.ImageJSON, sceneZipData)); // 保存场景中需打包的group数组 let groupArr: Group[] = []; // 处理 scene 子级 this.viewer.scene.children.forEach((child) => { if (child.ignore) return; // 3DTiles 处理 if (child.isTilesGroup || child.type === "TilesGroup") { const targetGroup = child.clone(false); const tilesJson = targetGroup.toJSON().object; sceneJson.object.children?.push(tilesJson); return; } if (child.type === "Group" || child.children?.length > 0) { sceneJson.object.children?.push(child.uuid); child.groupLayer = 1; groupArr.push(child as Group); child.traverseByCondition((c) => { // 不递归自身 if (c.uuid === child.uuid) return; // 3DTiles 此处不处理 if (c.isTilesGroup || c.type === "TilesGroup") return; if (c.type === "Group" || c.children?.length > 0) { c.groupLayer = c.parent.groupLayer + 1; if (c.groupLayer <= packConfig.layer || packConfig.layer === 0) { groupArr.push(c); } } }, (c) => !c.ignore) } else { this.handleMesh(child, sceneJson, sceneZipData); } }) // 将所有几何数据取出 单独存储 if (sceneJson.geometries) { // 为避免数据量过大超过V8引擎对于字符串2^32的限制,分为多个切片(10个几何数据为一组)json const transferNum = Math.ceil(sceneJson.geometries.length / 10); for (let i = 0; i < transferNum; i++) { const name = `geometries_${i}.json`; const geometry = JSON.stringify(sceneJson.geometries.slice(i * 10, (i + 1) * 10)); sceneZipData.push({ name, geometry }); } sceneJson.geometries = []; } const drawingInfo = App.project.getKey("drawing"); const sceneInfo = Object.assign(App.project.getKey("sceneInfo"),{ // 覆盖原zip包位置 zip:"", hasDrawing: drawingInfo.isUploaded ? 1 : 0, }); // 图纸 if (drawingInfo.isUploaded) { // 图片 sceneZipData.push({ name: sceneInfo.id + `.${BASE64_TYPES[drawingInfo.imgSrc.split(",")[0]]}`, drawing: drawingInfo.imgSrc }); // 标记 sceneZipData.push({ name: "drawingMark.txt", drawing: zip(drawingInfo.markList) }); // 图片信息(宽高信息等,以便于其他地方使用可计算标记左上距离百分比) sceneZipData.push({ name: "drawingImgInfo.json", drawing: JSON.stringify(drawingInfo.imgInfo) }); console.log(sceneZipData,drawingInfo) } const dataComponents = this.collectDataComponentMap(this.viewer.scene); if (Object.keys(dataComponents).length > 0) { sceneZipData.push({ name: "data_components.json", json: JSON.stringify(dataComponents) }); } // 项目配置 sceneZipData.push({ name: "config.json", json: JSON.stringify({ // 项目运行是否启用xr xr: App.project.getKey("xr"), // 项目渲染器配置 renderer: App.project.getKey("renderer"), // BVH 配置 bvh: App.project.getKey("bvh"), // 项目级联阴影映射 csm: App.project.getKey("csm"), // 项目后处理配置 effect: App.project.getKey("effect"), // 项目天气配置 weather: App.project.getKey('weather') }) }); const totalNum = groupArr.length + 1; sceneZipData.push({ name: "scene.json", json: JSON.stringify({ // 解包时需要还原的编辑器场景信息 metadata: App.metadata, camera: this.viewer.camera.toJSON(), scene: sceneJson, scripts: App.scripts, controls: { state: this.viewer.modules.controls.toJSON(), }, totalZipNumber: totalNum, sceneInfo: sceneInfo, }) }); // 首包上传 const firstUploadResult = await this.zip(sceneZipData, packConfig.name, packConfig.zipUploadFun, packConfig.rawUploadFun); // 进度 let progress = 0; packConfig.onProgress && packConfig.onProgress(parseFloat((progress / groupArr.length * 100).toFixed(2))); // 遍历打包group并上传 for (const group of groupArr) { // clone(false) 不克隆子元素 const g = group.clone(false); g.children = []; // 空 group let json: any = g.toJSON() as unknown as ITHREEScene.SceneJSON; json.geometries = []; json.images = []; json.textures = []; json.materials = []; json.object.uuid = group.uuid; json.object.children = []; // 存储待压缩数据 const zipData: SourceData[] = []; group.children.forEach((child) => { // 被groupArr包含的子级后面会单独处理,此处仅引用其uuid if (groupArr.find(item => item.uuid === child.uuid)) { json.object.children?.push(child.uuid); return; } // 3DTiles 处理 if (child.isTilesGroup || child.type === "TilesGroup") { const targetGroup = child.clone(false); const tilesJson = targetGroup.toJSON().object; json.object.children?.push(tilesJson); return; } this.handleMesh(child, json, zipData); }) // 将所有几何数据取出 单独存储 if (json.geometries) { // 为避免数据量过大超过V8引擎对于字符串2^32的限制,分为多个切片(10个几何数据为一组)json const transferNum = Math.ceil(json.geometries.length / 10); for (let i = 0; i < transferNum; i++) { const name = `geometries_${i}.json`; const geometry = JSON.stringify(json.geometries.slice(i * 10, (i + 1) * 10)); zipData.push({ name, geometry }); } json.geometries = []; } // json 打包 // 还原uuid json.object.uuid = group.uuid; const name = `${group.uuid}.json`; const content = JSON.stringify(json); zipData.push({ name, json: content }); await this.zip(zipData, group.uuid, packConfig.zipUploadFun, packConfig.rawUploadFun); progress++; packConfig.onProgress && packConfig.onProgress(parseFloat((progress / groupArr.length * 100).toFixed(2))); } // reset groupArr = []; this.geometryArr = []; this.imagesArr = []; this.materialsArr = []; this.textureArr = []; packConfig.onComplete && packConfig.onComplete({ firstUploadResult, totalSize: this.totalSize, totalZipNumber: totalNum }); return { firstUploadResult, totalSize: this.totalSize, totalZipNumber: totalNum }; } /** * zip 打包 * @param sourceData 待打包数据 * @param {string | number} zipName 打包文件名 * @return {Promise} 返回包上传接口结果 */ private toUint8(data: string | ArrayBuffer | Uint8Array): Uint8Array { if (data instanceof Uint8Array) return data; if (typeof data === "string") return strToU8(data); return new Uint8Array(data); } private buildFilesMapFromSourceData(sourceData: SourceData[]): Record { const files: Record = {}; sourceData.forEach((item) => { if (item.texture) { files[`Textures/${item.name}`] = this.toUint8(item.texture); } else if (item.geometry) { files[`Geometries/${item.name}`] = this.toUint8(item.geometry); } else if (item.json) { files[item.name] = this.toUint8(item.json); } else if (item.drawing) { files[`Drawing/${item.name}`] = this.toUint8(item.drawing); } }) return files; } private async zip( sourceData: SourceData[], zipName: string | number, zipUploadFun: (zip: File) => Promise, rawUploadFun?: (raw: { name: string; files: Record }) => Promise ): Promise { if (rawUploadFun) { const files = this.buildFilesMapFromSourceData(sourceData); const size = Object.values(files).reduce((sum, data) => sum + data.byteLength, 0); this.totalSize += size; return await rawUploadFun({ name: String(zipName), files }); } const jszip = new JSZip(); const imgFolder = jszip.folder("Textures") as JSZip; // 贴图文件夹 const geometriesFolder = jszip.folder("Geometries") as JSZip; // 几何数据文件夹 let drawingFolder = jszip.folder("Drawing") as JSZip; // 图纸文件夹 sourceData.forEach((item) => { if (item.texture) { imgFolder.file(item.name, item.texture, { compression: "DEFLATE",//"STORE",//"DEFLATE compressionOptions: { level: 7 } }); } else if (item.geometry) { geometriesFolder.file(item.name, item.geometry, { compression: "DEFLATE",//"STORE",//"DEFLATE compressionOptions: { level: 7 } }); } else if (item.json) { jszip.file(item.name, item.json, { compression: "DEFLATE",//"STORE",//"DEFLATE compressionOptions: { level: 7 } }); } else if (item.drawing) { drawingFolder.file(item.name, item.drawing, { compression: "DEFLATE",//"STORE",//"DEFLATE compressionOptions: { level: 9 } }); } }) const content = await jszip.generateAsync({ type: 'blob' }); const zipFile = new File([content], `${zipName}.zip`, { type: "application/zip" }); this.totalSize += zipFile.size; // 上传zip包 return await zipUploadFun(zipFile); } /* -------------------------------------------- 解包 --------------------------------------------------- */ /** * 还原几何数据 * @param arr * @private */ private unGzipGeometryJson(arr) { // 几何数据array 还原 function handler(value) { if (typeof value === "string") { return unzip(value, false); } return value; } return arr.map((geometry) => { if (geometry.data) { const data = geometry.data; if (data.attributes) { for (let key in data.attributes) { data.attributes[key].array = handler(data.attributes[key].array); } } if (data.index) { data.index.array = handler(data.index.array); } } return geometry; }) } /** * 还原贴图 * @param imageName * @param data */ private unGzipImage(imageName: string, data) { const nameSplit = imageName.split("."); if (nameSplit[1] === "env") { const urlSplit = nameSplit[0].split("!"); this.imagesMap.set(urlSplit[3], { uuid: urlSplit[3], url: { type: urlSplit[0], width: parseInt(urlSplit[1]), height: parseInt(urlSplit[2]), /** * sceneJson打zip包前原数据为Array,此处解压后我们使用ArrayBuffer,不还原为Array * 还原为Array这样写 Array.from(new TYPED_ARRAYS[urlSplit[0]](textureMap.get(urlSplit[3] + ".env"))) **/ data: data } }); } else { this.imagesMap.set(nameSplit[0], { uuid: nameSplit[0], url: data }); } } /** * 记录materials、texture、geometry已加载的uuid * @param object3D 模型json */ private recordUuid(object3D) { if (object3D.geometries) { object3D.geometries.forEach((geometry) => { this.geometryMap.set(geometry.uuid, geometry); }) } if (object3D.materials) { object3D.materials.forEach((material) => { this.materialsMap.set(material.uuid, material); }) } if (object3D.textures) { object3D.textures.forEach((texture) => { this.textureMap.set(texture.uuid, texture); }) } } private decodeUint8Text(data: Uint8Array): string { return new TextDecoder().decode(data); } private parseOfflineManifest(unzipped: Record): any | null { const manifestData = unzipped["offline.json"]; if (!manifestData) return null; try { return JSON.parse(this.decodeUint8Text(manifestData)); } catch { return null; } } private buildOfflineFlatPackages(unzipped: Record, packagesPrefix: string): Map> { const prefix = packagesPrefix.endsWith("/") ? packagesPrefix : `${packagesPrefix}/`; const map = new Map>(); for (const [name, data] of Object.entries(unzipped)) { if (name === "offline.json") continue; if (!name.startsWith(prefix)) continue; const rest = name.slice(prefix.length); const splitIndex = rest.indexOf("/"); if (splitIndex <= 0) continue; const baseName = rest.slice(0, splitIndex); const innerPath = rest.slice(splitIndex + 1); if (!innerPath) continue; let pkg = map.get(baseName); if (!pkg) { pkg = {}; map.set(baseName, pkg); } pkg[innerPath] = data; } return map; } private async unzipJsZipToMap(zipInput: Blob | ArrayBuffer | Uint8Array): Promise> { const jszip = new JSZip(); const zipRes = await jszip.loadAsync(zipInput as any); const out: Record = {}; for (const key in zipRes.files) { if (zipRes.files[key].dir) continue; const fileName = zipRes.files[key].name; const content = await zipRes.file(fileName)?.async("uint8array"); if (content) out[fileName] = content; } return out; } private async buildOfflinePackageMapFromFiles(files: File[]): Promise> { const map = new Map(); for (const file of files) { const buffer = new Uint8Array(await file.arrayBuffer()); const normalized = this.normalizeOfflinePackageName(file.name); map.set(normalized, buffer); const uuid = this.extractUuidFromName(file.name); if (uuid) { const canonical = this.normalizeOfflinePackageName(uuid.toLowerCase()); if (!map.has(canonical)) { map.set(canonical, buffer); } } } return map; } private async initOfflineSource(unpackConfig: IUnpackConfig): Promise { this.isOfflineImportContext = Boolean(unpackConfig.offlineBundle || (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0)); this.offlineZipMap = null; this.offlineEntryZip = null; this.offlineFlatPackages = null; this.offlineFlatEntryBase = null; const setupFlat = (unzipped: Record, manifest?: any, fallbackEntry?: string) => { const packagesPrefix = manifest?.packagesPrefix || this.offlinePackagesPrefix; this.offlineFlatPackages = this.buildOfflineFlatPackages(unzipped, packagesPrefix); const entryName = manifest?.entry ? this.normalizeOfflinePackageName(String(manifest.entry)) : (fallbackEntry ? this.normalizeOfflinePackageName(fallbackEntry) : undefined); const entryBase = manifest?.entryBase || (entryName ? this.getOfflinePackageBaseName(entryName) : undefined); if (entryBase) this.offlineFlatEntryBase = entryBase; if (entryName) this.offlineEntryZip = entryName; }; if (unpackConfig.offlineBundle) { let u8: Uint8Array; if (unpackConfig.offlineBundle instanceof Uint8Array) { u8 = unpackConfig.offlineBundle; } else if (unpackConfig.offlineBundle instanceof ArrayBuffer) { u8 = new Uint8Array(unpackConfig.offlineBundle); } else { u8 = new Uint8Array(await unpackConfig.offlineBundle.arrayBuffer()); } const unzipped = await this.unzipJsZipToMap(u8); const manifest = this.parseOfflineManifest(unzipped); if (manifest?.mode === "single") { setupFlat(unzipped, manifest); return; } const map = new Map(); for (const [name, data] of Object.entries(unzipped)) { if (name === "offline.json") continue; if (name.toLowerCase().endsWith(".astral")) { map.set(this.normalizeOfflinePackageName(name), data); } } this.offlineZipMap = map; if (manifest?.entry) { this.offlineEntryZip = this.normalizeOfflinePackageName(String(manifest.entry)); } else if (unpackConfig.offlineEntry) { this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry); } } else if (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0) { if (unpackConfig.offlineFiles.length === 1) { const file = unpackConfig.offlineFiles[0]; try { const unzipped = await this.unzipJsZipToMap(await file.arrayBuffer()); const manifest = this.parseOfflineManifest(unzipped); if (manifest?.mode === "single") { setupFlat(unzipped, manifest, file.name); return; } } catch (err) { // 兼容直接传入 .astral 分包文件的场景,忽略单包探测失败 } } this.offlineZipMap = await this.buildOfflinePackageMapFromFiles(unpackConfig.offlineFiles); if (unpackConfig.offlineEntry) { this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry); } } if (this.offlineZipMap && !this.offlineEntryZip) { this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.url); } } /** * 从首包开始解包 * @param {IUnpackConfig} unpackConfig */ public async unpack(unpackConfig: IUnpackConfig) { unpackConfig.onProgress && unpackConfig.onProgress(0); let totalZipNumber = 0, progress = 0; // 每次解包前确保 loader 可用 if (!this.loader) { this.loader = new ObjectLoader(); } await this.initOfflineSource(unpackConfig); if (!this.offlineZipMap && !this.offlineFlatPackages) { const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/); this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/"))); } else { this.prefix_url = ""; } // indexDb存储 // const db = window.VIEWPORT.modules["db"]; //const dbKey = `${useProjectState.getState().sceneId}-${useProjectState.getState().version.id}`; const that = this; this.callFunNum = new Proxy({ value: 0 }, { set(target, p, value) { if (target[p] < value) { progress += (value - target[p]) / totalZipNumber * 100; unpackConfig.onProgress && unpackConfig.onProgress(progress); } target[p] = value; if (value <= 0) { const done = () => { // 重置清除map that.geometryMap.clear(); that.imagesMap.clear(); that.materialsMap.clear(); that.textureMap.clear(); that.offlineZipMap = null; that.offlineEntryZip = null; that.offlineFlatPackages = null; that.offlineFlatEntryBase = null; that.dataComponentMap = null; // 不清除 loader,避免连续导入时被上一轮异步收尾误清理 } const complete = () => { done(); // 场景内容加载完毕后注入脚本执行逻辑 that.viewer.installScripts(); that.viewer.dispatchEvent({type:"loaded"}) that.skeletonClass.clear(); // 关闭IndexDB 否则新的标签页无法正常打开 // db.close(); unpackConfig.onComplete && unpackConfig.onComplete(); } complete(); } return true; } }); // map 存储 json 解析完成后执行的 function; key 为 uuid const funcMap = new Map(); const loadScene = (sceneJson: ISceneJson,drawingInfo:IDrawingInfo | null, configJson: IAppProject.Config) => { App.fromJSON(sceneJson).then(async (scene) => { // 还原控制器 if (sceneJson.controls?.state) { this.viewer.modules.controls.fromJSON(sceneJson.controls.state, true); } // 管理3DTiles scene.traverseByCondition((c) => { if (c.isTilesGroup || c.type === "TilesGroup") { this.viewer.addTiles(c,"none"); } }, (c) => !c.ignore) if (drawingInfo) { const projectDrawing = App.project.getKey("drawing"); projectDrawing.isCad = drawingInfo.imgSrc.split(".").pop() === "dxf"; projectDrawing.imgSrc = drawingInfo.imgSrc; projectDrawing.markList = drawingInfo.markList; projectDrawing.imgInfo = drawingInfo.imgInfo; projectDrawing.isUploaded = true; } // 还原项目配置 if (configJson) { App.project.setKey('xr',configJson.xr || false); if (configJson.renderer) { App.project.setKey("renderer",configJson.renderer); // fps需要通过 App.FPS 进行set才能正确计算单帧渲染时长 App.FPS = Number(configJson.renderer.fps); } const bvhConfig = (configJson as any)?.bvh; if (bvhConfig) { App.project.setKey("bvh", bvhConfig); } if(configJson.csm){ const projectCSM = App.project.getKey("csm"); let _csmNotChange = true; Object.keys(configJson.csm).forEach(csmKey => { if (projectCSM[csmKey] !== configJson.csm[csmKey]){ projectCSM[csmKey] = configJson.csm[csmKey]; _csmNotChange = false; } }) if (!_csmNotChange){ App.csm.enabled = projectCSM.enabled; } } if(configJson.effect){ Object.keys(configJson.effect).forEach((key) => { App.project.setKey(`effect.${key}`,configJson.effect[key]); }); } if (configJson.weather) { const projectWeather = App.project.getKey("weather"); if (configJson.weather.fog) { Object.keys(configJson.weather.fog).forEach((key) => { projectWeather.fog[key] = configJson.weather.fog[key]; }); useDispatchSignal("sceneFogSettingsChanged"); } if (configJson.weather.rain) { Object.keys(configJson.weather.rain).forEach((key) => { projectWeather.rain[key] = configJson.weather.rain[key]; }); useDispatchSignal("sceneRainSettingsChanged"); } if (configJson.weather.snow) { Object.keys(configJson.weather.snow).forEach((key) => { projectWeather.snow[key] = configJson.weather.snow[key]; }); useDispatchSignal("sceneSnowSettingsChanged"); } } } // 添加indexDB表存储zip包 // await db.addStore(dbKey); unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson); this.applyDataComponentMap(App.scene as unknown as Object3D); this.dataComponentMap = null; // 防止项目只有一个包的情况造成不触发proxy set if (this.callFunNum.value === 0) { this.callFunNum.value = 0; unpackConfig.onProgress && unpackConfig.onProgress(100); } // 开始执行funcMap中的function funcMap.forEach((func, uuid) => { func.call(this, uuid, scene, uuid); }) }) } const parseSceneZip = async (file: Blob | Uint8Array | ArrayBuffer) => { unpackConfig.onProgress && unpackConfig.onProgress(1); // @ts-ignore let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined; let dataComponentMap: Record | null = null; // 开始解压首包 const zip = new JSZip(); // 几何数据数组 let geometries: Array = []; // 图纸信息 let drawingInfo:IDrawingInfo = { imgSrc: "", markList: [], imgInfo: { width: 0, height: 0 } } const res = await zip.loadAsync(file as any); /** * res.files里包含整个zip里的文件描述、目录描述列表 * res本身就是JSZip的实例 */ for (let key in res.files) { //判断是否是目录 if (!res.files[key].dir) { const fileName = res.files[key].name; //找到我们压缩包所需要的json文件 if (fileName === "scene.json") { // 场景json const content = await res.file(fileName)?.async('string') as string; //得到scene.json文件的内容 sceneJson = JSON.parse(content); } else if (fileName === "config.json") { // 项目配置json const content = await res.file(fileName)?.async('string') as string; configJson = JSON.parse(content); } else if (fileName === "data_components.json") { const content = await res.file(fileName)?.async('string') as string; dataComponentMap = JSON.parse(content); } else if (fileName.substring(0, 9) === "Textures/") { /** * 贴图 * 分为两种情况: * 1.贴图为env格式(type!width!height!uuid.env),转换为arraybuffer格式,存入map * 2.贴图为普通图片格式,直接存入map **/ if (/\.env$/.test(fileName)) { // 转换回贴图原始信息,存入map const content = await res.file(fileName)?.async('arraybuffer'); this.unGzipImage(fileName.replace("Textures/", ""), content); } else if (/\.ktx2$/i.test(fileName)) { const content = await res.file(fileName)?.async('arraybuffer') as ArrayBuffer; const blob = new Blob([content], { type: "image/ktx2" }); const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`; this.unGzipImage(fileName.replace("Textures/", ""), blobUrl); } else { const content = await res.file(fileName)?.async('string') this.unGzipImage(fileName.replace("Textures/", ""), content); } } else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) { const content = await res.file(fileName)?.async('string') as string; geometries.push(...this.unGzipGeometryJson(JSON.parse(content))); } else if (/^Geometries\/geometries_\d*\.zip$/.test(fileName)) { /** 此处为兼容整体打包的版本 **/ // geometry切片zip包,内部是json文件 const content = await res.file(fileName)?.async('blob') as Blob; const zip = new JSZip(); const zipRes = await zip.loadAsync(content); for (let zipKey in zipRes.files) { const content = await zip.file(zipRes.files[zipKey].name)?.async('string') as string; geometries.push(...this.unGzipGeometryJson(JSON.parse(content))); } } else if (fileName.substring(0, 8) === "Drawing/") { /** * 图纸文件夹下的文件 * 1. drawingMarking.txt 为图纸标注文件,须解压 * 2. sceneId开头的图片是图纸 */ if (res.files[key].name === "Drawing/drawingMark.txt") { const content = await res.file(res.files[key].name)?.async('string') as string; drawingInfo.markList = unzip(content) } else if (res.files[key].name === "Drawing/drawingImgInfo.json") { drawingInfo.imgInfo = JSON.parse(await res.file(res.files[key].name)?.async('string') as string); } else { drawingInfo.imgSrc = await res.file(res.files[key].name)?.async('string') as string; } } } } totalZipNumber = sceneJson.totalZipNumber || 0; this.dataComponentMap = dataComponentMap; // 贴图还原至sceneJson sceneJson.scene.images = sceneJson.scene.images.map((item) => { const nameSplit = item.split("."); if (nameSplit[1] === "env") { const urlSplit = nameSplit[0].split("!"); return this.imagesMap.get(urlSplit[3]) } else { return this.imagesMap.get(nameSplit[0]) } }); // 几何数据还原至sceneJson sceneJson.scene.geometries = geometries; this.recordUuid(sceneJson.scene); const newChildren: any = []; // 遍历sceneJson.object.children,拉取group zip还原 sceneJson.scene.object.children?.forEach(objectJsonOruuid => { if (typeof objectJsonOruuid === "string") { // 保存uuid对应的function funcMap.set(objectJsonOruuid, this.unpackGroup); this.callFunNum.value++; } else { newChildren.push(objectJsonOruuid) } }) sceneJson.scene.object.children = newChildren; // 图档信息 const _drawingInfo = drawingInfo.imgSrc ? drawingInfo : null; loadScene(sceneJson,_drawingInfo, configJson); //sceneJson.scene.groupChildren = [...funcMap.keys()]; // 解压处理好的数据添加至 indexDB -> Msy3D -> scene // db.setItem(dbKey, sceneJson); } const networkGet = async () => { if (this.offlineFlatPackages) { const entryBase = this.offlineFlatEntryBase || this.getOfflinePackageBaseName(this.offlineEntryZip || unpackConfig.url); const entry = this.offlineFlatPackages.get(entryBase); if (!entry) { console.error(`[Package] 离线包缺少首包内容: ${entryBase}`); return; } const zipData = await this.zipRawFiles(entry); await parseSceneZip(zipData); return; } if (this.offlineZipMap) { const entryName = this.offlineEntryZip || this.normalizeOfflinePackageName(unpackConfig.url); const zipData = this.offlineZipMap.get(entryName); if (!zipData) { console.error(`[Package] 离线包缺少首包: ${entryName}`); return; } await parseSceneZip(zipData); return; } // 下载场景包 const file = await fetch(this.viewer.options.request?.baseUrl + unpackConfig.url).then(zipRes => zipRes.blob()); await parseSceneZip(file); } // db.getItem(dbKey).then((dbData) => { // if (dbData === undefined) { await networkGet(); // }else{ // this.recordUuid(dbData.scene); // // dbData.scene.images.forEach((image) => { // this.imagesMap.set(image.uuid, image); // }) // // dbData.scene.groupChildren.forEach((uuid) => { // // 保存uuid对应的function // funcMap.set(uuid, this.unpackGroup); // // this.callFunNum.value++; // }) // // loadScene(dbData); // } // }) } /** * 异步解压group zip * @param uuid * @param parent * @param rootGroupUuid */ private unpackGroup(uuid: string, parent, rootGroupUuid) { //const db = window.VIEWPORT.modules["db"]; //const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`; // 在调用时捕获 loader 引用,防止 done() 清除 this.loader 后重试路径报错 const loader = this.loader; // map 存储 json 解析完成后执行的 function; key 为 uuid const funcMap = new Map(); const check = (object, group) => { // 检查数据是否已完善 let isDone = true; const useMaterials = new Set(); object.children.forEach((child) => { // 检查几何数据是否都已拥有 if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) { if (!this.geometryMap.has(child.geometry)) { isDone = false; } else { group.geometries.push(this.geometryMap.get(child.geometry)); } } // material->texture->image if (child.material) { const materialUUIDs = Array.isArray(child.material) ? child.material : [child.material]; materialUUIDs.forEach((materialUuid) => useMaterials.add(materialUuid)); } if (child.children?.length > 0 && isDone) { isDone = check(child, group); } }) if (!isDone) return isDone; const useTextures = new Set(); const textureFields = [ "map", "alphaMap", "aoMap", "anisotropyMap", "bumpMap", "clearcoatMap", "clearcoatNormalMap", "clearcoatRoughnessMap", "displacementMap", "envMap", "emissiveMap", "iridescenceMap", "iridescenceThicknessMap", "lightMap", "sheenColorMap", "sheenRoughnessMap", "specularColorMap", "specularIntensityMap", "specularMap", "thicknessMap", "transmissionMap", "metalnessMap", "roughnessMap", "normalMap", "gradientMap" ]; useMaterials.forEach((materialUuid) => { if (!isDone) return; const material = this.materialsMap.get(materialUuid); if (!material) { isDone = false; return; } if (group.materials?.findIndex((item) => item.uuid === materialUuid) === -1) { group.materials.push(material); } textureFields.forEach((field) => { const textureUuid = material[field]; if (textureUuid) useTextures.add(textureUuid); }); }); if (!isDone) return isDone; const useImages = new Set(); useTextures.forEach((textureUuid) => { if (!isDone) return; const texture = this.textureMap.get(textureUuid); if (!texture) { isDone = false; return; } if (group.textures?.findIndex((item) => item.uuid === textureUuid) === -1) { group.textures.push(texture); } if (texture.image) useImages.add(texture.image); }); if (!isDone) return isDone; useImages.forEach((imageUuid) => { if (!isDone) return; if (!this.imagesMap.has(imageUuid)) { isDone = false; return; } if (group.images?.findIndex((item) => item.uuid === imageUuid) === -1) { group.images.push(this.imagesMap.get(imageUuid)); } }); return isDone; } const parse = (json) => { if (check(json.object, json)) { if (!loader) { // 防止早退导致 callFunNum 无法归零,进度卡住 this.callFunNum.value--; return; } try { loader.parse(json, (group) => { const bones: Bone[] = []; group.getObjectsByProperty("type", "Bone", bones); if (bones.length > 0) { this.skeletonClass.addBones(bones); } // 如果存在Skeleton(骨架),须存下来后面替换回原骨骼。 // 因为loader.parse时如果对应骨骼(Bone)还未加载,会生成新的空骨骼替代, if (json.skeletons) { this.skeletonClass.handleSkeletons(json.skeletons, group); } group.uuid = uuid; // requestIdleCallback(()=>{ App.addObject(group, parent); // 管理3DTiles group.traverseByCondition((c) => { if (c.isTilesGroup || c.type === "TilesGroup") { this.viewer.addTiles(c,"none"); } }, (c) => !c.ignore) this.callFunNum.value--; // }) // 开始执行funcMap中的function funcMap.forEach((func, uuid) => { func.call(this, uuid, group, rootGroupUuid); }) }) } catch (err) { throw err; } } else { const timer = setTimeout(() => { clearTimeout(timer); parse(json); }, 200) } } const parseGroupZip = async (file: Blob | Uint8Array | ArrayBuffer) => { const zip = new JSZip(); let json: GroupJson; // 几何数据数组 let geometries: Array = []; const unzipDone = () => { // 贴图还原至sceneJson json.images = json.images.map((item) => { const nameSplit = item.split("."); if (nameSplit[1] === "env") { const urlSplit = nameSplit[0].split("!"); return this.imagesMap.get(urlSplit[3]) } else { return this.imagesMap.get(nameSplit[0]) } }); // 几何数据还原至sceneJson json.geometries = geometries; this.recordUuid(json); // 遍历children,拉取group zip还原 const children: any = []; json.object.children.forEach((childUuid) => { if (typeof childUuid === "string") { // 保存uuid对应的function funcMap.set(childUuid, this.unpackGroup); this.callFunNum.value++; } else { children.push(childUuid) } }) json.object.children = children; parse(json); } const res = await zip.loadAsync(file as any); let num = new Proxy({ value: Object.keys(res.files).length }, { set(target, p, value) { target[p] = value; if (value === 0) { unzipDone(); } return true; } }) for (let key in res.files) { //判断是否是目录 if (!res.files[key].dir) { const fileName = res.files[key].name; //找到我们压缩包所需要的json文件 if (fileName === `${uuid}.json`) { // 场景json res.file(fileName)?.async('string').then(content => { json = JSON.parse(content); num.value--; }) } else if (fileName.substring(0, 9) === "Textures/") { if (/\.env$/.test(fileName)) { // 转换回贴图原始信息,存入map res.file(fileName)?.async('arraybuffer').then(content => { this.unGzipImage(fileName.replace("Textures/", ""), content); num.value--; }) } else if (/\.ktx2$/i.test(fileName)) { res.file(fileName)?.async('arraybuffer').then(content => { const blob = new Blob([content], { type: "image/ktx2" }); const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`; this.unGzipImage(fileName.replace("Textures/", ""), blobUrl); num.value--; }) } else { res.file(fileName)?.async('string').then(content => { this.unGzipImage(fileName.replace("Textures/", ""), content); num.value--; }) } } else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) { res.file(fileName)?.async('string').then(content => { geometries.push(...this.unGzipGeometryJson(JSON.parse(content))); num.value--; }) } else { num.value--; } } else { num.value--; } } } const getByNetwork = () => { if (this.offlineFlatPackages) { const entry = this.offlineFlatPackages.get(uuid); if (!entry) { console.error(`[Package] 离线包缺少分包内容: ${uuid}`); this.callFunNum.value--; return; } this.zipRawFiles(entry).then((zipData) => { parseGroupZip(zipData); }); return; } if (this.offlineZipMap) { const fileName = this.normalizeOfflinePackageName(uuid); const normalizedLowerName = this.normalizeOfflinePackageName(uuid.toLowerCase()); const zipData = this.offlineZipMap.get(fileName) || this.offlineZipMap.get(normalizedLowerName); if (!zipData) { console.error(`[Package] 离线包缺少分包: ${fileName}`); this.callFunNum.value--; return; } parseGroupZip(zipData); return; } Package._fetch(`${this.prefix_url}/${uuid}.zip`, { onSuccess: (zipRes) => { parseGroupZip(zipRes.blob()); } }); } // db.getItem(`${uuid}.zip`, dbTable).then((dbData:GroupJson | undefined) => { // if (dbData === undefined) { getByNetwork(); // } else { // this.recordUuid(dbData); // // dbData.images.forEach((image) => { // this.imagesMap.set(image.uuid, image); // }) // // dbData.object.groupChildren.forEach((uuid) => { // // 保存uuid对应的function // funcMap.set(uuid, this.unpackGroup); // // this.callFunNum.value++; // }) // // parse(dbData); // } // }).catch((err: string) => { // console.log("err:", err) // }) } /** * 销毁此类 */ dispose(){ // 1. 清空所有数组 this.geometryArr = []; this.imagesArr = []; this.materialsArr = []; this.textureArr = []; this.skeletonsArr = []; // 2. 清空所有映射 this.geometryMap.clear(); this.imagesMap.clear(); this.materialsMap.clear(); this.textureMap.clear(); // 3. 销毁 loader 和骨架处理器 if (this.loader) { this.loader = null as any; // 清空引用 } if (this.skeletonClass) { this.skeletonClass.clear(); // 清除骨架数据 this.skeletonClass = null as any; // 清空引用 } // 4. 重置其他属性 this.prefix_url = ""; this.callFunNum = { value: 0 }; // 重置为初始状态 this.totalSize = 0; this.offlineZipMap = null; this.offlineEntryZip = null; this.offlineFlatPackages = null; this.offlineFlatEntryBase = null; this.dataComponentMap = null; // 5. 释放 viewer 引用(注意:不销毁 viewer,仅移除引用) this.viewer = null as any; } /** * 导出离线包(单包或多包) */ async packOffline(offlineConfig: IOfflinePackConfig) { const mode = offlineConfig.mode || "multiple"; const packageItems: Array<{ baseName: string; data: Uint8Array }> = []; const rawPackages: Array<{ baseName: string; files: Record }> = []; const packResult = await this.pack({ name: offlineConfig.name, layer: mode === "single" ? 1 : offlineConfig.layer, imageProcessLimit: offlineConfig.imageProcessLimit, zipUploadFun: async (zip: File) => { if (mode === "single") { return zip; } const data = new Uint8Array(await zip.arrayBuffer()); const baseName = this.getOfflinePackageBaseName(zip.name); packageItems.push({ baseName, data }); return zip; }, rawUploadFun: mode === "single" ? async (raw) => { const baseName = this.getOfflinePackageBaseName(raw.name); rawPackages.push({ baseName, files: raw.files }); return raw; } : undefined, onZipProgress: offlineConfig.onZipProgress, onProgress: offlineConfig.onProgress, }); const entry = this.normalizeOfflinePackageName(offlineConfig.name); const entryBase = this.getOfflinePackageBaseName(entry); if (mode === "single") { const filesMap: Record = {}; for (const item of rawPackages) { for (const [fileName, fileData] of Object.entries(item.files)) { const fullName = `${this.offlinePackagesPrefix}${item.baseName}/${fileName}`; if (!filesMap[fullName]) { filesMap[fullName] = fileData; } } } const manifest = { mode: "single", entry, entryBase, packagesPrefix: this.offlinePackagesPrefix, totalZipNumber: packResult.totalZipNumber, }; filesMap["offline.json"] = strToU8(JSON.stringify(manifest)); const bundleName = this.normalizeOfflinePackageName(offlineConfig.bundleName || `${offlineConfig.name}-offline`); const bundleZip = await this.zipRawFiles(filesMap); const bundleFile = new File([bundleZip as any], bundleName, { type: "application/octet-stream" }); const result: OfflinePackResult = { mode: "single", entry, files: [bundleFile], totalSize: bundleFile.size, totalZipNumber: packResult.totalZipNumber, }; offlineConfig.onComplete && offlineConfig.onComplete(result); return result; } const files = packageItems.map(item => new File([item.data as any], this.normalizeOfflinePackageName(item.baseName), { type: "application/octet-stream" })); const totalSize = files.reduce((sum, file) => sum + file.size, 0); const result: OfflinePackResult = { mode: "multiple", entry, files, totalSize, totalZipNumber: packResult.totalZipNumber, }; offlineConfig.onComplete && offlineConfig.onComplete(result); return result; } /** * 将原始文件映射压缩为 zip 数据 */ private async zipRawFiles(files: Record): Promise { const jszip = new JSZip(); for (const [name, data] of Object.entries(files)) { jszip.file(name, data); } return await jszip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); } /** * 统一离线包名称(.astral) */ private normalizeOfflinePackageName(name: string): string { const baseName = name.split(/[\\/]/).pop() || name; const cleanName = baseName.split(/[?#]/)[0]; const lower = cleanName.toLowerCase(); if (lower.endsWith(".zip")) { return cleanName.slice(0, -4) + ".astral"; } return cleanName + ".astral"; } /** * 获取离线包基础名称(不含后缀) */ private getOfflinePackageBaseName(name: string): string { const baseName = name.split(/[\\/]/).pop() || name; const cleanName = baseName.split(/[?#]/)[0]; if (cleanName.toLowerCase().endsWith(".astral")) { return cleanName.slice(0, -7); } if (cleanName.toLowerCase().endsWith(".zip")) { return cleanName.slice(0, -4); } return cleanName; } /** * 尝试从文件名中提取UUID(处理"xxx (1).astral"等情况) */ // @ts-ignore private extractUuidFromName(name: string): string | null { const match = name.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/); return match ? match[0] : null; } }