feat(all): 离线包迁移

This commit is contained in:
plum
2026-04-08 20:51:11 +08:00
parent 309bb0a5e6
commit 404e804143
3 changed files with 476 additions and 123 deletions
+211 -9
View File
@@ -7,6 +7,7 @@
*/
import { Mesh, Group, Bone } 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";
@@ -16,18 +17,70 @@ import App from "@/core/app/App";
import type Viewer from "@/core/viewer/Viewer";
interface IPackConfig {
name: string; // 首包名称
layer?: number; // 拆分的最深层级 0:拆分至最深层
zipUploadFun: (zip: File) => Promise<any>; // 压缩包上传接口函数,多压缩包
onProgress?: (progress: number) => void; // 打包进度回调
onComplete?: (_: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => void; // 打包完成回调
// 首包名称
name: string;
// 拆分的最深层级 0:拆分至最深层
layer?: number;
// 压缩包上传接口函数,多压缩包
zipUploadFun: (zip: File) => Promise<any>;
// 原始包上传接口函数(不压缩)
rawUploadFun?: (raw: { name: string; files: Record<string, Uint8Array> }) => Promise<any>;
// 压缩进度回调(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 为单 zipmultiple 为多 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: string, // 首包url
onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void, // 场景首包加载完成回调
onProgress?: (progress: number) => void; // 场景加载进度回调
onComplete?: () => void // 场景加载完成回调.
// 首包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 {
@@ -71,6 +124,19 @@ export class Package {
private callFunNum: { value: number; };
private skeletonClass: PackageSkeleton;
// 离线包相关属性
// @ts-ignore
private offlineZipMap: Map<string, Uint8Array> | null = null;
// @ts-ignore
private offlineEntryZip: string | null = null;
// @ts-ignore
private offlineFlatPackages: Map<string, Record<string, Uint8Array>> | null = null;
// @ts-ignore
private offlineFlatEntryBase: string | null = null;
private readonly offlinePackagesPrefix = "Packages/";
// @ts-ignore
private isOfflineImportContext = false;
constructor(viewer:Viewer) {
this.viewer = viewer;
@@ -1199,4 +1265,140 @@ export class Package {
// 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<string, Uint8Array> }> = [];
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<string, Uint8Array> = {};
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<string, Uint8Array>): Promise<Uint8Array> {
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;
}
}