mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
Budget GLTF external resources before parse
This commit is contained in:
parent
5cfb554e19
commit
b87d5c96bb
4 changed files with 841 additions and 583 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Performance: include external `.gltf` buffers and textures in pre-parse render budgeting so large resource-backed scenes start with safer quality settings.
|
||||
- Performance: defer reading-mode `3d` and `3dgrid` code block runtime imports until the rendered block approaches the viewport, reducing workspace restore work for long notes with offscreen model blocks.
|
||||
- Performance: deduplicate and limit concurrent Three.js `.gltf` external buffer/texture reads, reducing I/O and memory spikes for large resource-heavy models.
|
||||
- Performance: reuse cached Three.js renderable indexes while disposing switched or closed models, reducing repeated scene-tree walks for large assemblies.
|
||||
|
|
|
|||
1164
main.js
1164
main.js
File diff suppressed because one or more lines are too long
|
|
@ -40,9 +40,26 @@ function summary(tier: ModelPreviewSummary["performanceTier"]): ModelPreviewSumm
|
|||
}
|
||||
|
||||
function createAppWithVaultFileSize(path: string, size: number): App {
|
||||
return createAppWithVaultFiles({ [path]: { size } });
|
||||
}
|
||||
|
||||
function createAppWithVaultFiles(files: Record<string, { size: number; text?: string }>): App {
|
||||
const fileMap = new Map(
|
||||
Object.entries(files).map(([path, file]) => [path, { path, stat: { size: file.size }, text: file.text }]),
|
||||
);
|
||||
return {
|
||||
vault: {
|
||||
getAbstractFileByPath: (candidate: string) => candidate === path ? { stat: { size } } : null,
|
||||
getAbstractFileByPath: (candidate: string) => fileMap.get(candidate) ?? null,
|
||||
read: async (file: { path?: string; text?: string }) => {
|
||||
if (typeof file.text === "string") {
|
||||
return file.text;
|
||||
}
|
||||
const matched = file.path ? fileMap.get(file.path) : null;
|
||||
if (typeof matched?.text === "string") {
|
||||
return matched.text;
|
||||
}
|
||||
throw new Error("missing vault text fixture");
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
}
|
||||
|
|
@ -97,6 +114,82 @@ describe("model render budget", () => {
|
|||
expect(nodeShimMocks.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds relative glTF external resource sizes without loading Node shims", async () => {
|
||||
nodeShimMocks.moduleLoadCount.value = 0;
|
||||
nodeShimMocks.stat.mockReset();
|
||||
|
||||
const gltfText = JSON.stringify({
|
||||
buffers: [{ uri: "assembly.bin", byteLength: 128 }],
|
||||
images: [{ uri: "textures/diffuse%20map.png" }],
|
||||
});
|
||||
const app = createAppWithVaultFiles({
|
||||
"models/assembly.gltf": { size: 1024, text: gltfText },
|
||||
"models/assembly.bin": { size: 70 * 1024 * 1024 },
|
||||
"models/textures/diffuse map.png": { size: 2 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
|
||||
.resolves.toBe(1024 + 72 * 1024 * 1024);
|
||||
|
||||
expect(nodeShimMocks.moduleLoadCount.value).toBe(0);
|
||||
expect(nodeShimMocks.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates glTF resources after URI suffix normalization", async () => {
|
||||
const gltfText = JSON.stringify({
|
||||
buffers: [{ uri: "shared.bin" }],
|
||||
images: [{ uri: "shared.bin?cache=1" }],
|
||||
});
|
||||
const app = createAppWithVaultFiles({
|
||||
"models/assembly.gltf": { size: 1024, text: gltfText },
|
||||
"models/shared.bin": { size: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
|
||||
.resolves.toBe(1024 + 10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("resolves parent-directory glTF external resource paths from the model folder", async () => {
|
||||
const gltfText = JSON.stringify({
|
||||
images: [{ uri: "../textures/panel.png" }],
|
||||
});
|
||||
const app = createAppWithVaultFiles({
|
||||
"models/nested/assembly.gltf": { size: 1024, text: gltfText },
|
||||
"models/textures/panel.png": { size: 4 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await expect(getModelPathByteSize(app, "models/nested/assembly.gltf"))
|
||||
.resolves.toBe(1024 + 4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("falls back to declared glTF buffer byteLength when external stat is missing", async () => {
|
||||
const gltfText = JSON.stringify({
|
||||
buffers: [{ uri: "missing.bin", byteLength: 68 * 1024 * 1024 }],
|
||||
});
|
||||
const app = createAppWithVaultFiles({
|
||||
"models/assembly.gltf": { size: 1024, text: gltfText },
|
||||
});
|
||||
|
||||
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
|
||||
.resolves.toBe(1024 + 68 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("skips remote and data glTF resource URIs when estimating local byte size", async () => {
|
||||
const gltfText = JSON.stringify({
|
||||
buffers: [
|
||||
{ uri: "https://cdn.example.com/huge.bin", byteLength: 512 * 1024 * 1024 },
|
||||
{ uri: "data:application/octet-stream;base64,AA==", byteLength: 512 * 1024 * 1024 },
|
||||
],
|
||||
images: [{ uri: "data:image/png;base64,AA==" }],
|
||||
});
|
||||
const app = createAppWithVaultFiles({
|
||||
"models/assembly.gltf": { size: 2048, text: gltfText },
|
||||
});
|
||||
|
||||
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
|
||||
.resolves.toBe(2048);
|
||||
});
|
||||
|
||||
it("uses Node stat only for absolute filesystem paths", async () => {
|
||||
nodeShimMocks.moduleLoadCount.value = 0;
|
||||
nodeShimMocks.stat.mockResolvedValueOnce({ size: 654321 });
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ export type RenderQualityBudget = Pick<PluginSettings, "renderQuality" | "render
|
|||
|
||||
const MEDIUM_FILE_SIZE_BYTES = 64 * 1024 * 1024;
|
||||
const HEAVY_FILE_SIZE_BYTES = 192 * 1024 * 1024;
|
||||
const REMOTE_URI_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
interface GltfExternalResource {
|
||||
uri?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
interface GltfSizeManifest {
|
||||
buffers?: GltfExternalResource[];
|
||||
images?: GltfExternalResource[];
|
||||
}
|
||||
|
||||
function settingsBudget(settings: RenderQualityBudget): RenderQualityBudget {
|
||||
return {
|
||||
|
|
@ -67,7 +78,70 @@ export function looksLikeAbsoluteFilesystemPath(path: string): boolean {
|
|||
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
||||
}
|
||||
|
||||
function stripUriSuffix(uri: string): string {
|
||||
return uri.split(/[?#]/, 1)[0] ?? uri;
|
||||
}
|
||||
|
||||
function decodePortableUri(uri: string): string {
|
||||
try {
|
||||
return decodeURIComponent(uri);
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePortableRelativePath(path: string): string {
|
||||
const decoded = decodePortableUri(path).replace(/\\/g, "/");
|
||||
const parts: string[] = [];
|
||||
for (const part of decoded.split("/")) {
|
||||
if (!part || part === ".") {
|
||||
continue;
|
||||
}
|
||||
if (part === "..") {
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function joinPortablePath(basePath: string, relativePath: string): string {
|
||||
if (!basePath) {
|
||||
return normalizePortableRelativePath(stripUriSuffix(relativePath));
|
||||
}
|
||||
return normalizePortableRelativePath(`${basePath}/${stripUriSuffix(relativePath)}`);
|
||||
}
|
||||
|
||||
function getPortableDirname(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const sepIdx = normalized.lastIndexOf("/");
|
||||
return sepIdx > 0 ? normalized.slice(0, sepIdx) : "";
|
||||
}
|
||||
|
||||
function shouldSkipExternalResourceUri(uri: string | undefined): boolean {
|
||||
if (!uri || uri.startsWith("data:")) {
|
||||
return true;
|
||||
}
|
||||
return REMOTE_URI_RE.test(uri) && !looksLikeAbsoluteFilesystemPath(uri);
|
||||
}
|
||||
|
||||
function normalizedResourceKey(uri: string): string {
|
||||
return normalizePortableRelativePath(stripUriSuffix(uri));
|
||||
}
|
||||
|
||||
export async function getModelPathByteSize(app: App, path: string): Promise<number | null> {
|
||||
const baseSize = await getSinglePathByteSize(app, path);
|
||||
if (baseSize === null) {
|
||||
return null;
|
||||
}
|
||||
if (!path.toLowerCase().split(/[?#]/, 1)[0].endsWith(".gltf")) {
|
||||
return baseSize;
|
||||
}
|
||||
return estimateGltfAggregateByteSize(app, path, baseSize);
|
||||
}
|
||||
|
||||
async function getSinglePathByteSize(app: App, path: string): Promise<number | null> {
|
||||
if (looksLikeAbsoluteFilesystemPath(path)) {
|
||||
try {
|
||||
const { stat } = await import("../utils/node-shim");
|
||||
|
|
@ -82,6 +156,96 @@ export async function getModelPathByteSize(app: App, path: string): Promise<numb
|
|||
return isFileWithSize(file) ? file.stat.size : null;
|
||||
}
|
||||
|
||||
async function readGltfText(app: App, path: string): Promise<string | null> {
|
||||
if (looksLikeAbsoluteFilesystemPath(path)) {
|
||||
try {
|
||||
const { readFile } = await import("../utils/node-shim");
|
||||
return new TextDecoder().decode(await readFile(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
const vault = app.vault as typeof app.vault & { read?: (file: TFile) => Promise<string> };
|
||||
if (!file || typeof vault.read !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await vault.read(file as TFile);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getExternalResourceByteSize(app: App, modelPath: string, uri: string): Promise<number | null> {
|
||||
if (looksLikeAbsoluteFilesystemPath(modelPath)) {
|
||||
try {
|
||||
const { pathJoin, pathNormalize, stat } = await import("../utils/node-shim");
|
||||
const cleanUri = stripUriSuffix(decodePortableUri(uri));
|
||||
const modelDir = pathNormalize(modelPath.replace(/[\\/][^\\/]*$/, ""));
|
||||
const resourcePath = looksLikeAbsoluteFilesystemPath(cleanUri)
|
||||
? pathNormalize(cleanUri)
|
||||
: pathNormalize(pathJoin(modelDir, cleanUri));
|
||||
const stats = await stat(resourcePath);
|
||||
return stats.size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const modelDir = getPortableDirname(modelPath);
|
||||
const resourcePath = joinPortablePath(modelDir, uri);
|
||||
const file = app.vault.getAbstractFileByPath(resourcePath);
|
||||
return isFileWithSize(file) ? file.stat.size : null;
|
||||
}
|
||||
|
||||
async function estimateGltfAggregateByteSize(app: App, path: string, baseSize: number): Promise<number> {
|
||||
const text = await readGltfText(app, path);
|
||||
if (!text) {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
let manifest: GltfSizeManifest;
|
||||
try {
|
||||
manifest = JSON.parse(text) as GltfSizeManifest;
|
||||
} catch {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
let total = baseSize;
|
||||
const seen = new Set<string>();
|
||||
const addResource = async (resource: GltfExternalResource | undefined, fallbackByteLength?: number): Promise<void> => {
|
||||
const uri = resource?.uri;
|
||||
if (!uri || shouldSkipExternalResourceUri(uri)) {
|
||||
return;
|
||||
}
|
||||
const key = normalizedResourceKey(uri);
|
||||
if (!key || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const size = await getExternalResourceByteSize(app, path, uri);
|
||||
if (size !== null && Number.isFinite(size) && size > 0) {
|
||||
total += size;
|
||||
return;
|
||||
}
|
||||
const declaredSize = resource?.byteLength ?? fallbackByteLength;
|
||||
if (Number.isFinite(declaredSize) && (declaredSize ?? 0) > 0) {
|
||||
total += declaredSize ?? 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const buffer of manifest.buffers ?? []) {
|
||||
await addResource(buffer, buffer.byteLength);
|
||||
}
|
||||
for (const image of manifest.images ?? []) {
|
||||
await addResource(image);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function isFileWithSize(value: unknown): value is TFile {
|
||||
const file = value as Partial<TFile> | null | undefined;
|
||||
return !!file &&
|
||||
|
|
|
|||
Loading…
Reference in a new issue