Advance M8-M11 parity workflows
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-17 04:37:07 -04:00
parent 7c16b279ae
commit 0fe8d2bb56
324 changed files with 31920 additions and 863 deletions

View File

@@ -1,19 +1,59 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRecentProjectsResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { LODCacheRecord } from "../../../protocol/lod";
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import type { RecentProjectRecord } from "../../../protocol/recent-projects";
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "../../../protocol/texture-paint-asset";
export interface StorageClientOptions {
workerFactory?: () => Worker;
onWorkerFault?: (fault: WorkerFault) => void;
}
const defaultWorkerFactory = () => new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
export class StorageClient {
private readonly worker: Worker;
private readonly workerFactory: () => Worker;
private readonly onWorkerFault?: (fault: WorkerFault) => void;
private worker: Worker | null = null;
private workerFaulted = false;
private lastWorkerFault: WorkerFault | null = null;
private counter = 0;
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number }>();
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number; cleanupAbort?: () => void }>();
constructor() {
this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<StorageResponse>) => {
constructor(options: StorageClientOptions = {}) {
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
this.onWorkerFault = options.onWorkerFault;
this.start();
}
async restart(): Promise<StorageInfoResult> {
this.worker?.terminate();
this.worker = null;
this.workerFaulted = false;
this.lastWorkerFault = null;
return this.info();
}
crashForTest(): void {
this.worker?.postMessage({
requestId: `storage-crash-${++this.counter}`,
command: { type: "crashForTest" },
} satisfies StorageRequest);
}
private start(): Worker {
if (this.workerFaulted) {
throw this.lastWorkerFault ? this.toError(this.lastWorkerFault) : new Error("StorageWorker 需要重启");
}
if (this.worker) return this.worker;
const worker = this.workerFactory();
worker.onmessage = (event: MessageEvent<StorageResponse>) => {
const pending = this.pending.get(event.data.requestId);
if (!pending) return;
this.pending.delete(event.data.requestId);
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
if (event.data.ok && event.data.result) pending.resolve(event.data.result);
else {
const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string };
@@ -21,14 +61,10 @@ export class StorageClient {
pending.reject(error);
}
};
this.worker.onerror = (event) => {
const error = new Error(event.message || "StorageWorker error");
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
};
worker.onerror = (event) => this.handleWorkerFault(event.message || "StorageWorker error");
worker.onmessageerror = () => this.handleWorkerFault("StorageWorker 消息无法解析");
this.worker = worker;
return worker;
}
smoke(): Promise<StorageSmokeResult> {
@@ -39,10 +75,30 @@ export class StorageClient {
return this.request({ type: "info" }) as Promise<StorageInfoResult>;
}
getBudget(projectId: string): Promise<StorageBudgetResult> {
return this.request({ type: "getBudget", projectId }) as Promise<StorageBudgetResult>;
}
listRecentProjects(): Promise<StorageRecentProjectsResult> {
return this.request({ type: "listRecentProjects" }) as Promise<StorageRecentProjectsResult>;
}
touchRecentProject(project: RecentProjectRecord): Promise<StorageRecentProjectsResult> {
return this.request({ type: "touchRecentProject", project }) as Promise<StorageRecentProjectsResult>;
}
removeRecentProject(projectId: string): Promise<StorageRecentProjectsResult> {
return this.request({ type: "removeRecentProject", projectId }) as Promise<StorageRecentProjectsResult>;
}
ensureProject(projectId: string): Promise<StorageProjectResult> {
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
}
cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
return this.request({ type: "cleanupProject", projectId }) as Promise<StorageProjectCleanupResult>;
}
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
}
@@ -91,6 +147,14 @@ export class StorageClient {
return this.request({ type: "listAssets", projectId }) as Promise<StorageAssetListResult>;
}
commitTexturePaintTile(commit: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
return this.request({ type: "commitTexturePaintTile", commit }) as Promise<TexturePaintTileCommitResultIR>;
}
readTexturePaintTileBinding(request: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
return this.request({ type: "readTexturePaintTileBinding", request }) as Promise<TexturePaintTileBindingResultIR>;
}
saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise<StorageLODResult>;
}
@@ -119,45 +183,111 @@ export class StorageClient {
return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise<StorageLODPruneResult>;
}
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise<StorageSimulationCacheResult> {
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise<StorageSimulationCacheResult>;
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer, signal?: AbortSignal): Promise<StorageSimulationCacheResult> {
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data], signal) as Promise<StorageSimulationCacheResult>;
}
readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
prepareSimulationCachePlayback(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCachePlaybackReadyResult> {
return this.request({ type: "prepareSimulationCachePlayback", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCachePlaybackReadyResult>;
}
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
releaseSimulationCachePlayback(projectId: string, cacheKey: string): Promise<StorageSimulationCachePlaybackReleaseResult> {
return this.request({ type: "releaseSimulationCachePlayback", projectId, cacheKey }) as Promise<StorageSimulationCachePlaybackReleaseResult>;
}
readSimulationCache(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCacheReadResult> {
return this.request({ type: "readSimulationCache", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCacheReadResult>;
}
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, signal?: AbortSignal): Promise<StorageSimulationCacheFrameReadResult> {
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }, [], signal) as Promise<StorageSimulationCacheFrameReadResult>;
}
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}
pruneSimulationCaches(projectId: string, maxBytes: number, protectedCacheKeys: string[] = []): Promise<StorageSimulationCachePruneResult> {
return this.request({ type: "pruneSimulationCaches", projectId, maxBytes, protectedCacheKeys }) as Promise<StorageSimulationCachePruneResult>;
}
getPendingRequestCount(): number {
return this.pending.size;
}
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
private request(command: StorageRequest["command"], transfer: Transferable[] = [], signal?: AbortSignal): Promise<NonNullable<StorageResponse["result"]>> {
if (signal?.aborted) return Promise.reject(new DOMException("Storage request aborted", "AbortError"));
let worker: Worker;
try {
worker = this.start();
}
catch (error) {
return Promise.reject(error);
}
const requestId = `storage-${++this.counter}`;
const request: StorageRequest = { requestId, command };
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
pending.cleanupAbort?.();
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
reject(new Error("StorageWorker timeout"));
}, 10_000);
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout });
this.worker.postMessage(request, transfer);
const abort = () => {
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
pending.reject(new DOMException("Storage request aborted", "AbortError"));
};
const cleanupAbort = signal ? () => signal.removeEventListener("abort", abort) : undefined;
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout, cleanupAbort });
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
worker.postMessage(request, transfer);
});
}
private handleWorkerFault(message: string): void {
if (this.workerFaulted) return;
const fault = createWorkerFault("storage", message);
this.workerFaulted = true;
this.lastWorkerFault = fault;
const worker = this.worker;
this.worker = null;
const error = this.toError(fault);
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
pending.reject(error);
}
this.pending.clear();
worker?.terminate();
this.onWorkerFault?.(fault);
}
private toError(fault: WorkerFault): Error {
const error = new Error(fault.error.message) as Error & { code?: string };
error.code = fault.error.code;
return error;
}
terminate(): void {
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
pending.reject(new Error("StorageClient terminated"));
}
this.pending.clear();
this.worker.terminate();
this.worker?.terminate();
this.worker = null;
this.workerFaulted = true;
}
}

View File

@@ -1,7 +1,7 @@
export const STORAGE_DATABASE_NAME = "blender-web-metadata";
export const STORAGE_SCHEMA_VERSION = 6;
export const STORAGE_SCHEMA_VERSION = 7;
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const;
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"] as const;
export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void {
if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) {
@@ -31,4 +31,8 @@ export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransactio
if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() });
}
if (oldVersion < 7) {
if (!db.objectStoreNames.contains("simulation_quarantine")) db.createObjectStore("simulation_quarantine", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-7", version: 7, appliedAt: new Date().toISOString() });
}
}

View File

@@ -203,6 +203,32 @@ export async function ensureProjectLayout(projectId: string, storage: StorageMan
return layout;
}
export async function measureProjectVDBBytes(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise<number> {
const layout = projectLayout(projectId);
const manager = storage as OpfsStorage | undefined;
if (!manager?.getDirectory) return 0;
try {
let current = await manager.getDirectory();
for (const segment of `${layout.cachePath}/vdb`.split("/")) current = await current.getDirectoryHandle(segment);
let total = 0;
const entries = (current as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [, handle] of entries) {
if (handle.kind !== "directory") continue;
const bundle = handle as FileSystemDirectoryHandle;
const files = (bundle as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [name, fileHandle] of files) {
if (fileHandle.kind !== "file" || !name.endsWith(".chunk")) continue;
total += (await (fileHandle as FileSystemFileHandle).getFile()).size;
}
}
return Number.isSafeInteger(total) ? total : 0;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return 0;
throw error;
}
}
export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise<OpfsProjectLayout> {
const layout = await ensureProjectLayout(projectId, storage);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
@@ -310,6 +336,66 @@ export async function readProjectBlend(projectId: string, storage?: StorageManag
return (await file.getFile()).arrayBuffer();
}
function snapshotFileName(revision: number): string {
if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid snapshot revision");
return `${revision}.blend`;
}
export async function writeProjectSnapshot(
projectId: string,
revision: number,
data: ArrayBuffer,
storage?: StorageManager,
): Promise<{ layout: OpfsProjectLayout; path: string }> {
if (data.byteLength === 0) throw new Error("Project snapshot buffer is empty");
const fileName = snapshotFileName(revision);
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
const stageName = `${revision}.${crypto.randomUUID()}.tmp`;
const sha256 = await sha256Hex(data);
try {
await writeFile(snapshots, stageName, data);
if (!await verifyFile(snapshots, stageName, data.byteLength, sha256)) {
throw new Error("SNAPSHOT_STAGE_VERIFY_FAILED: staged snapshot does not match its digest");
}
const stage = await snapshots.getFileHandle(stageName);
const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
if (move) await move.call(stage, fileName);
else {
await writeFile(snapshots, fileName, await (await stage.getFile()).arrayBuffer());
await removeFile(snapshots, stageName);
}
if (!await verifyFile(snapshots, fileName, data.byteLength, sha256)) {
throw new Error("SNAPSHOT_COMMIT_VERIFY_FAILED: committed snapshot does not match its digest");
}
return { layout, path: `${layout.snapshotsPath}/${fileName}` };
}
catch (error) {
await removeFile(snapshots, stageName);
throw error;
}
}
export async function readProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
return readFile(snapshots, snapshotFileName(revision));
}
export async function deleteProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<void> {
const layout = projectLayout(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
await removeFile(snapshots, snapshotFileName(revision));
}
export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise<ProjectBlendRecoveryResult> {
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
@@ -377,6 +463,71 @@ export async function writeContentAsset(projectId: string, sha256: string, data:
return { layout, path, deduplicated: false };
}
export interface OrphanContentAssetCleanupResult {
removed: number;
bytes: number;
paths: string[];
}
export async function removeOrphanContentAssets(projectId: string, referencedSha256: ReadonlySet<string>, storage?: StorageManager): Promise<OrphanContentAssetCleanupResult> {
const layout = projectLayout(projectId);
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) return { removed: 0, bytes: 0, paths: [] };
const root = await manager.getDirectory();
let assets: FileSystemDirectoryHandle;
try {
let current = root;
for (const segment of layout.assetsPath.split("/")) current = await current.getDirectoryHandle(segment);
assets = current;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return { removed: 0, bytes: 0, paths: [] };
throw error;
}
let removed = 0;
let bytes = 0;
const paths: string[] = [];
const shaRoot = await assets.getDirectoryHandle("sha256", { create: false }).catch((error) => {
if (error instanceof DOMException && error.name === "NotFoundError") return undefined;
throw error;
});
if (!shaRoot) return { removed, bytes, paths };
const prefixes = (shaRoot as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [prefix, prefixHandle] of prefixes) {
if (prefixHandle.kind !== "directory" || !/^[a-f0-9]{2}$/.test(prefix)) continue;
const directory = prefixHandle as FileSystemDirectoryHandle;
const files = (directory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [name, fileHandle] of files) {
if (fileHandle.kind !== "file") continue;
if (/^[a-f0-9]{64}$/.test(name) && referencedSha256.has(name)) continue;
const size = (await (fileHandle as FileSystemFileHandle).getFile()).size;
await directory.removeEntry(name);
removed += 1;
bytes += size;
paths.push(`${layout.assetsPath}/sha256/${prefix}/${name}`);
}
}
return { removed, bytes, paths };
}
export async function deleteContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<void> {
const layout = projectLayout(projectId);
validateSha256(sha256);
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) return;
const root = await manager.getDirectory();
try {
let current = root;
for (const segment of `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`.split("/")) {
current = await current.getDirectoryHandle(segment);
}
await current.removeEntry(sha256);
}
catch (error) {
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
}
}
export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
validateSha256(sha256);