Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d013f34493 |
@@ -0,0 +1,19 @@
|
|||||||
|
# 三维场景静态资源下载与缓存
|
||||||
|
|
||||||
|
`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
|
||||||
|
|
||||||
|
- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
|
||||||
|
- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
|
||||||
|
- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
|
||||||
|
- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
|
||||||
|
- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
|
||||||
|
|
||||||
|
反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
|
||||||
|
|
||||||
|
聚焦回归:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
const root = "/three-dimensional/zjb/v29/";
|
const root = "/three-dimensional/zjb/v29/";
|
||||||
const preview = root + "preview.html?host=platform2&v=35-resource-cache";
|
const preview = root + "preview.html?host=platform2&v=36-resumable-cache";
|
||||||
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
@@ -28,7 +28,7 @@ test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试
|
|||||||
expect(cached).toBe(false);
|
expect(cached).toBe(false);
|
||||||
await page.getByRole("button", { name: "重新载入场景" }).click();
|
await page.getByRole("button", { name: "重新载入场景" }).click();
|
||||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||||
expect(requests).toBe(2);
|
expect(requests).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
||||||
@@ -46,7 +46,7 @@ test("模型和元数据跨页面复用持久缓存,即使服务器资源不
|
|||||||
await page.goto(preview);
|
await page.goto(preview);
|
||||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||||
const coldMs = Date.now() - coldStart;
|
const coldMs = Date.now() - coldStart;
|
||||||
expect(requests).toHaveLength(10);
|
expect(new Set(requests).size).toBe(10);
|
||||||
requests.length = 0;
|
requests.length = 0;
|
||||||
block = true;
|
block = true;
|
||||||
await page.close();
|
await page.close();
|
||||||
@@ -140,7 +140,7 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
|
|||||||
await page.route("**/cache-probe.json*", (route) => {
|
await page.route("**/cache-probe.json*", (route) => {
|
||||||
requests++;
|
requests++;
|
||||||
return requests === 1
|
return requests === 1
|
||||||
? route.fulfill({ status: 503, body: "unavailable" })
|
? route.fulfill({ status: 404, body: "not found" })
|
||||||
: requests === 2
|
: requests === 2
|
||||||
? route.fulfill({ body: "invalid json" })
|
? route.fulfill({ body: "invalid json" })
|
||||||
: route.fulfill({ json: { ok: true } });
|
: route.fulfill({ json: { ok: true } });
|
||||||
@@ -160,6 +160,145 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
|
|||||||
}, root);
|
}, root);
|
||||||
expect(requests).toBe(3);
|
expect(requests).toBe(3);
|
||||||
expect(result.errors).toHaveLength(2);
|
expect(result.errors).toHaveLength(2);
|
||||||
expect(result.errors[0]).toContain("HTTP 503");
|
expect(result.errors[0]).toContain("HTTP 404");
|
||||||
expect(result.recovered).toEqual({ ok: true });
|
expect(result.recovered).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const { createHash } = await import("node:crypto");
|
||||||
|
const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb");
|
||||||
|
const ranges: string[] = [];
|
||||||
|
let interrupted = false;
|
||||||
|
await page.route("**/models/detail_interior.glb*", async (route) => {
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
ranges.push(range ?? "whole-file");
|
||||||
|
// The public proxy drops whole-file responses, and one partial request drops too.
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
if (range.startsWith("bytes=1048576-") && !interrupted) {
|
||||||
|
interrupted = true;
|
||||||
|
return route.abort("failed");
|
||||||
|
}
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
return route.fulfill({ status: 206, headers: {
|
||||||
|
"content-type": "model/gltf-binary",
|
||||||
|
"content-range": `bytes ${start}-${end}/${bytes.length}`,
|
||||||
|
}, body: bytes.subarray(start, end + 1) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery");
|
||||||
|
const data = await load();
|
||||||
|
const cached = await load();
|
||||||
|
return { length: data.byteLength, cachedLength: cached.byteLength,
|
||||||
|
sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") };
|
||||||
|
}, root);
|
||||||
|
expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||||
|
expect(ranges).toHaveLength(7);
|
||||||
|
expect(ranges[0]).toBe("bytes=0-1048575");
|
||||||
|
expect(ranges[1]).toBe(ranges[2]);
|
||||||
|
expect(interrupted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fault of ["offset", "total", "truncated", "whole-response"] as const) {
|
||||||
|
test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => {
|
||||||
|
const bytes = Buffer.alloc(1048576 + 16, 7);
|
||||||
|
let broken = true;
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/range-probe.bin*", (route) => {
|
||||||
|
requests++;
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes });
|
||||||
|
return route.fulfill({ status: 206, headers: {
|
||||||
|
"content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`,
|
||||||
|
}, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const failed = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; }
|
||||||
|
catch { return true; }
|
||||||
|
}, root);
|
||||||
|
expect(failed).toBe(true);
|
||||||
|
const before = requests;
|
||||||
|
broken = false;
|
||||||
|
const length = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength;
|
||||||
|
}, root);
|
||||||
|
expect(length).toBe(bytes.length);
|
||||||
|
// The valid first segment survives; only the rejected final segment is fetched again.
|
||||||
|
expect(requests - before).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => {
|
||||||
|
const bytes = Buffer.alloc(2 * 1048576 + 16, 9);
|
||||||
|
let disconnected = true;
|
||||||
|
const ranges: string[] = [];
|
||||||
|
await page.route("**/resume-probe.bin*", (route) => {
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
ranges.push(range ?? "whole-file");
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
if (disconnected && start > 0) return route.abort("failed");
|
||||||
|
return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const load = (base: string) => page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try {
|
||||||
|
const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first");
|
||||||
|
return { length: data.byteLength, failed: false };
|
||||||
|
} catch { return { failed: true }; }
|
||||||
|
}, base);
|
||||||
|
expect(await load(root)).toEqual({ failed: true });
|
||||||
|
expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3);
|
||||||
|
disconnected = false;
|
||||||
|
ranges.length = 0;
|
||||||
|
await page.reload();
|
||||||
|
expect(await load(root)).toEqual({ length: bytes.length, failed: false });
|
||||||
|
expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/retry-probe.json*", (route) => {
|
||||||
|
requests++;
|
||||||
|
return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
const stages: string[] = [];
|
||||||
|
const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage));
|
||||||
|
const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
|
||||||
|
return { data, retries: stages.filter(stage => stage === "retry").length };
|
||||||
|
}, root);
|
||||||
|
expect(result).toEqual({ data: { ok: true }, retries: 2 });
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); });
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const failed = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try {
|
||||||
|
await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
|
||||||
|
return false;
|
||||||
|
} catch { return true; }
|
||||||
|
}, root);
|
||||||
|
expect(failed).toBe(true);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ export function reportProgress(update) {
|
|||||||
const done = values.filter(item => item.stage === 'ready').length;
|
const done = values.filter(item => item.stage === 'ready').length;
|
||||||
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
||||||
const cached = values.filter(item => item.stage === 'cache').length;
|
const cached = values.filter(item => item.stage === 'cache').length;
|
||||||
|
const retrying = values.some(item => item.stage === 'retry');
|
||||||
const parsing = values.some(item => item.stage === 'parse');
|
const parsing = values.some(item => item.stage === 'parse');
|
||||||
const downloading = values.some(item => item.stage === 'download');
|
const downloading = values.some(item => item.stage === 'download');
|
||||||
progress.max = Math.max(1, values.length);
|
progress.max = Math.max(1, values.length);
|
||||||
progress.value = done;
|
progress.value = done;
|
||||||
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
||||||
detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finishLoading() {
|
export function finishLoading() {
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<output id="qa" hidden></output>
|
<output id="qa" hidden></output>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import {failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
import {failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
|
||||||
import('./preview.mjs?v=35-resource-cache').catch(failLoading);
|
import('./preview.mjs?v=36-resumable-cache').catch(failLoading);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {createRenderEffects} from './render-effects.mjs';
|
import {createRenderEffects} from './render-effects.mjs';
|
||||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache';
|
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache';
|
||||||
import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache';
|
import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
|
||||||
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
|
||||||
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
const DATABASE = 'tjwater-scene-assets-v1';
|
const DATABASE = 'tjwater-scene-assets-v1';
|
||||||
const STORE = 'resources';
|
const STORE = 'resources';
|
||||||
const REQUEST_TIMEOUT_MS = 30_000;
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
const CHUNK_BYTES = 1024 * 1024;
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -54,39 +56,111 @@ export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
|
|||||||
|
|
||||||
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
||||||
if (revision) url.searchParams.set('v', revision);
|
if (revision) url.searchParams.set('v', revision);
|
||||||
const controller = new AbortController();
|
const partialKey = key + '#partial';
|
||||||
let timer;
|
let chunks = [], loaded = 0, total = 0;
|
||||||
const resetTimeout = () => {
|
if (storage && revision) {
|
||||||
clearTimeout(timer);
|
try {
|
||||||
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const partial = await transact(storage, 'readonly', store => store.get(partialKey));
|
||||||
};
|
if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
|
||||||
report('download');
|
Array.isArray(partial.chunks) && partial.chunks.length &&
|
||||||
resetTimeout();
|
partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
|
||||||
let data;
|
const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||||
try {
|
if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
|
||||||
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
|
|
||||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
|
||||||
const total = Number(response.headers.get('content-length')) || 0;
|
|
||||||
if (response.body) {
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const chunks = [];
|
|
||||||
let loaded = 0;
|
|
||||||
while (true) {
|
|
||||||
const {done, value} = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value); loaded += value.byteLength;
|
|
||||||
report('download', loaded, total); resetTimeout();
|
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(loaded);
|
} catch { /* A denied partial cache does not prevent downloading. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPart() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer;
|
||||||
|
const resetTimeout = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
resetTimeout();
|
||||||
|
try {
|
||||||
|
const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1;
|
||||||
|
const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = Error('HTTP ' + response.status);
|
||||||
|
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
let partTotal = 0, expected = 0;
|
||||||
|
if (response.status === 206) {
|
||||||
|
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
|
||||||
|
const start = Number(range?.[1]), last = Number(range?.[2]);
|
||||||
|
partTotal = Number(range?.[3]);
|
||||||
|
if (!range || start !== loaded || last < start || last > end || last >= partTotal ||
|
||||||
|
!Number.isSafeInteger(partTotal) || (total && total !== partTotal)) {
|
||||||
|
throw Error('资源分段响应无效');
|
||||||
|
}
|
||||||
|
expected = last - start + 1;
|
||||||
|
} else {
|
||||||
|
if (loaded) throw Error('服务器未按断点返回资源');
|
||||||
|
// Servers without Range support can still return an ordinary complete response.
|
||||||
|
partTotal = Number(response.headers.get('content-length')) || 0;
|
||||||
|
}
|
||||||
|
const buffers = [];
|
||||||
|
let size = 0;
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const {done, value} = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffers.push(value); size += value.byteLength;
|
||||||
|
if (expected && size > expected) throw Error('资源分段长度无效');
|
||||||
|
report('download', loaded + size, partTotal); resetTimeout();
|
||||||
|
}
|
||||||
|
} finally { await reader.cancel().catch(() => {}); }
|
||||||
|
} else {
|
||||||
|
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||||
|
buffers.push(buffer); size = buffer.byteLength;
|
||||||
|
}
|
||||||
|
if (expected && size !== expected) throw Error('资源分段不完整');
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
|
||||||
data = bytes.buffer;
|
return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
|
||||||
} else {
|
} catch (error) {
|
||||||
data = await response.arrayBuffer();
|
controller.abort();
|
||||||
|
throw error;
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
report('download', loaded, total);
|
||||||
|
try {
|
||||||
|
while (!total || loaded < total) {
|
||||||
|
let part;
|
||||||
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||||
|
try { part = await downloadPart(); break; }
|
||||||
|
catch (error) {
|
||||||
|
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
|
||||||
|
if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error;
|
||||||
|
if (attempt + 1 === MAX_ATTEMPTS) throw error;
|
||||||
|
report('retry', loaded, total);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks.push(part.data); loaded += part.data.byteLength; total = part.total;
|
||||||
|
if (storage && revision && part.ranged) {
|
||||||
|
try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); }
|
||||||
|
catch { /* Storage denied/full: retain the in-memory download. */ }
|
||||||
|
}
|
||||||
|
if (!part.ranged) break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
|
throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
|
||||||
} finally { clearTimeout(timer); }
|
}
|
||||||
|
const bytes = new Uint8Array(loaded);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; }
|
||||||
|
const data = bytes.buffer;
|
||||||
|
if (storage) {
|
||||||
|
try { await transact(storage, 'readwrite', store => store.delete(partialKey)); }
|
||||||
|
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
|
||||||
|
}
|
||||||
|
|
||||||
report('parse', data.byteLength, data.byteLength);
|
report('parse', data.byteLength, data.byteLength);
|
||||||
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
type SceneRuntimeState,
|
type SceneRuntimeState,
|
||||||
} from "./sceneProtocol";
|
} from "./sceneProtocol";
|
||||||
|
|
||||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache";
|
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
|
||||||
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
const initialRuntimeState: SceneRuntimeState = {
|
const initialRuntimeState: SceneRuntimeState = {
|
||||||
|
|||||||
Reference in New Issue
Block a user