Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
2
web/app/public/vendor/blender/web_engine.js
vendored
2
web/app/public/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
87
web/app/src/assets/AssetPreviewDisplay.ts
Normal file
87
web/app/src/assets/AssetPreviewDisplay.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { planAssetPreviewDecode } from "../../../protocol/asset-preview-decode";
|
||||
import type { AssetPreviewIdentityIR } from "../../../protocol/asset-preview";
|
||||
|
||||
export type AssetPreviewDisplayBackend = "MAIN_THREAD_CANVAS_2D" | "OFFSCREEN_CANVAS_2D";
|
||||
|
||||
export interface AssetPreviewDisplayReceiptIR {
|
||||
schemaVersion: 1;
|
||||
status: "READY";
|
||||
backend: AssetPreviewDisplayBackend;
|
||||
identitySha256: string;
|
||||
contentSha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelByteLength: number;
|
||||
pixelSha256: string;
|
||||
nonTransparentPixels: number;
|
||||
bitmapClosed: true;
|
||||
}
|
||||
|
||||
export interface AssetPreviewDisplayResultIR {
|
||||
receipt: AssetPreviewDisplayReceiptIR;
|
||||
pixels: Uint8Array;
|
||||
}
|
||||
|
||||
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||
|
||||
async function sha256Bytes(value: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(value).buffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function displayAssetPreview(
|
||||
canvas: PreviewCanvas,
|
||||
backend: AssetPreviewDisplayBackend,
|
||||
identityValue: unknown,
|
||||
encodedBytes: ArrayBuffer,
|
||||
): Promise<AssetPreviewDisplayResultIR> {
|
||||
const plan = await planAssetPreviewDecode(identityValue, encodedBytes);
|
||||
if (typeof createImageBitmap !== "function") throw new Error("CAPABILITY_MISSING: createImageBitmap is unavailable");
|
||||
const identity = identityValue as AssetPreviewIdentityIR;
|
||||
const bitmap = await createImageBitmap(new Blob([encodedBytes], { type: plan.mimeType }), {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
imageOrientation: "none",
|
||||
});
|
||||
try {
|
||||
if (bitmap.width !== plan.width || bitmap.height !== plan.height) {
|
||||
throw new Error("ASSET_PREVIEW_IDENTITY_MISMATCH: decoded preview dimensions changed");
|
||||
}
|
||||
canvas.width = plan.width;
|
||||
canvas.height = plan.height;
|
||||
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true }) as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (!context) throw new Error("CAPABILITY_MISSING: preview Canvas2D context is unavailable");
|
||||
context.clearRect(0, 0, plan.width, plan.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.globalCompositeOperation = "copy";
|
||||
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
|
||||
const pixels = new Uint8Array(context.getImageData(0, 0, plan.width, plan.height).data);
|
||||
let nonTransparentPixels = 0;
|
||||
for (let offset = 3; offset < pixels.byteLength; offset += 4) if (pixels[offset] !== 0) nonTransparentPixels++;
|
||||
const receipt: AssetPreviewDisplayReceiptIR = {
|
||||
schemaVersion: 1,
|
||||
status: "READY",
|
||||
backend,
|
||||
identitySha256: identity.identitySha256,
|
||||
contentSha256: identity.content.sha256,
|
||||
width: plan.width,
|
||||
height: plan.height,
|
||||
pixelByteLength: pixels.byteLength,
|
||||
pixelSha256: await sha256Bytes(pixels),
|
||||
nonTransparentPixels,
|
||||
bitmapClosed: true,
|
||||
};
|
||||
if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
|
||||
canvas.dataset.assetPreviewStatus = "ready";
|
||||
canvas.dataset.assetPreviewBackend = backend;
|
||||
canvas.dataset.assetPreviewPixelSha256 = receipt.pixelSha256;
|
||||
}
|
||||
return { receipt, pixels };
|
||||
}
|
||||
finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
PaintStrokeSessionReceiptIR,
|
||||
} from "../../../protocol/paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: WebEngineResult) => void;
|
||||
@@ -122,6 +123,28 @@ export class WebEngineClient {
|
||||
};
|
||||
}
|
||||
|
||||
async appendLibraryObject(
|
||||
source: ArrayBuffer,
|
||||
request: LibraryMainAppendRequestIR,
|
||||
): Promise<BlendOpenResult & { delta: SceneDelta; receipt: LibraryMainAppendReceiptIR }> {
|
||||
const result = await this.request({ type: "appendLibraryObject", request, source }, [source]);
|
||||
if (!result.snapshot || !result.delta || !result.libraryAppend) {
|
||||
throw this.report("ASSET_MANIFEST_INVALID", "WebEngine did not return the committed library append", true);
|
||||
}
|
||||
this.geometryBuffers = result.geometryDelta
|
||||
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
|
||||
: result.geometryBuffers ?? this.geometryBuffers;
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
|
||||
return {
|
||||
status: result.status,
|
||||
snapshot: result.snapshot,
|
||||
geometryBuffers: [...this.geometryBuffers],
|
||||
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
|
||||
delta: result.delta,
|
||||
receipt: result.libraryAppend,
|
||||
};
|
||||
}
|
||||
|
||||
async beginPaintStroke(session: PaintStrokeSessionBeginIR): Promise<PaintStrokeSessionReceiptIR> {
|
||||
const result = await this.request({ type: "beginPaintStroke", session });
|
||||
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not open the paint pointer session", true);
|
||||
|
||||
@@ -42,7 +42,13 @@ async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise<void> {
|
||||
function cancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
||||
@@ -50,12 +56,16 @@ function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise
|
||||
window.clearTimeout(timeout);
|
||||
video.removeEventListener("loadeddata", ready);
|
||||
video.removeEventListener("error", failed);
|
||||
signal?.removeEventListener("abort", aborted);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const failed = (): void => finish(new Error("decode"));
|
||||
const aborted = (): void => finish(new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled"));
|
||||
video.addEventListener("loadeddata", ready, { once: true });
|
||||
video.addEventListener("error", failed, { once: true });
|
||||
signal?.addEventListener("abort", aborted, { once: true });
|
||||
if (signal?.aborted) aborted();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +74,9 @@ export async function generateInitialSequencerMovieProxyFrame(
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceData: ArrayBuffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SequencerGeneratedProxyFrameIR> {
|
||||
cancelled(signal);
|
||||
const source = parseSequencerCodecProbeRequest(sourceValue);
|
||||
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
||||
const profile = parseSequencerMediaProxyProfile(profileValue);
|
||||
@@ -75,6 +87,7 @@ export async function generateInitialSequencerMovieProxyFrame(
|
||||
if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
|
||||
}
|
||||
cancelled(signal);
|
||||
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
|
||||
}
|
||||
@@ -87,7 +100,8 @@ export async function generateInitialSequencerMovieProxyFrame(
|
||||
try {
|
||||
video.src = url;
|
||||
video.load();
|
||||
await waitForLoadedFrame(video, 10_000);
|
||||
await waitForLoadedFrame(video, 10_000, signal);
|
||||
cancelled(signal);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = profile.width;
|
||||
canvas.height = profile.height;
|
||||
@@ -97,6 +111,7 @@ export async function generateInitialSequencerMovieProxyFrame(
|
||||
}
|
||||
context.clearRect(0, 0, profile.width, profile.height);
|
||||
context.drawImage(video, 0, 0, profile.width, profile.height);
|
||||
cancelled(signal);
|
||||
const pixels = context.getImageData(0, 0, profile.width, profile.height).data;
|
||||
const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength);
|
||||
return {
|
||||
|
||||
236
web/app/src/storage/asset-catalog-indexeddb.ts
Normal file
236
web/app/src/storage/asset-catalog-indexeddb.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
migrateAssetCatalogV1ToV2,
|
||||
type AssetCatalogMigrationReportIR,
|
||||
} from "../../../protocol/asset-catalog-migration";
|
||||
import {
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogManifestV2IR,
|
||||
} from "../../../protocol/asset-catalog-v2";
|
||||
import type { ErrorCode } from "../../../protocol/error";
|
||||
|
||||
export const ASSET_CATALOG_INDEX_V1_ID = "asset-catalog:index:v1" as const;
|
||||
export const ASSET_CATALOG_INDEX_V2_ID = "asset-catalog:index:v2" as const;
|
||||
export const ASSET_CATALOG_MIGRATION_ID = "asset-catalog:migration:v1-to-v2" as const;
|
||||
|
||||
export type AssetCatalogMigrationFault = "AFTER_TARGET_PUT" | "AFTER_SOURCE_DELETE";
|
||||
|
||||
export interface AssetCatalogIndexRowV1 {
|
||||
id: typeof ASSET_CATALOG_INDEX_V1_ID;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface AssetCatalogIndexRowV2 {
|
||||
id: typeof ASSET_CATALOG_INDEX_V2_ID;
|
||||
value: AssetCatalogManifestV2IR;
|
||||
}
|
||||
|
||||
export interface AssetCatalogIndexedDBMigrationReceiptIR {
|
||||
schemaVersion: 1;
|
||||
id: typeof ASSET_CATALOG_MIGRATION_ID;
|
||||
task: "M12-01G";
|
||||
status: "MIGRATED";
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
sourceManifestSha256: string;
|
||||
targetManifestSha256: string;
|
||||
}
|
||||
|
||||
export interface AssetCatalogIndexedDBMigrationResultIR {
|
||||
status: "MIGRATED" | "ALREADY_MIGRATED";
|
||||
manifest: AssetCatalogManifestV2IR;
|
||||
receipt: AssetCatalogIndexedDBMigrationReceiptIR;
|
||||
migrationReport: AssetCatalogMigrationReportIR | null;
|
||||
}
|
||||
|
||||
export interface AssetCatalogIndexedDBSnapshotIR {
|
||||
schemaVersion: 1;
|
||||
revision: number;
|
||||
manifestSha256: string;
|
||||
catalogOrder: Array<{ catalogId: string; path: string }>;
|
||||
assetOrder: string[];
|
||||
assetIdentities: Array<{
|
||||
assetId: string;
|
||||
assetLibraryIdentifier: string | null;
|
||||
relativeAssetIdentifier: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class AssetCatalogIndexedDBMigrationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(`${code}: ${message}`, options);
|
||||
this.name = "AssetCatalogIndexedDBMigrationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: unknown): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(stableJSON(value));
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction failed"));
|
||||
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", `Catalog index lookup failed for ${id}`));
|
||||
});
|
||||
}
|
||||
|
||||
function assertStores(database: IDBDatabase, settingStore: string, migrationStore: string): void {
|
||||
if (!database.objectStoreNames.contains(settingStore) || !database.objectStoreNames.contains(migrationStore)) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration stores are unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function parseReceipt(value: unknown): AssetCatalogIndexedDBMigrationReceiptIR {
|
||||
const digest = /^[a-f0-9]{64}$/;
|
||||
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_CATALOG_MIGRATION_ID || value.task !== "M12-01G" || value.status !== "MIGRATED" ||
|
||||
!Number.isSafeInteger(value.sourceRevision) || Number(value.sourceRevision) < 0 ||
|
||||
!Number.isSafeInteger(value.targetRevision) || Number(value.targetRevision) < 0 ||
|
||||
typeof value.sourceManifestSha256 !== "string" || !digest.test(value.sourceManifestSha256) ||
|
||||
typeof value.targetManifestSha256 !== "string" || !digest.test(value.targetManifestSha256)) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Stored catalog migration receipt is invalid");
|
||||
}
|
||||
return value as unknown as AssetCatalogIndexedDBMigrationReceiptIR;
|
||||
}
|
||||
|
||||
export async function readAssetCatalogIndexedDBIndex(
|
||||
database: IDBDatabase,
|
||||
settingStore = "setting",
|
||||
migrationStore = "migration",
|
||||
): Promise<{ source: StoredRow | undefined; target: StoredRow | undefined; receipt: StoredRow | undefined }> {
|
||||
assertStores(database, settingStore, migrationStore);
|
||||
const transaction = database.transaction([settingStore, migrationStore], "readonly");
|
||||
const sourcePromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V1_ID);
|
||||
const targetPromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
|
||||
const receiptPromise = readRow(transaction.objectStore(migrationStore), ASSET_CATALOG_MIGRATION_ID);
|
||||
const result = await Promise.all([sourcePromise, targetPromise, receiptPromise]);
|
||||
await transactionComplete(transaction, () => undefined);
|
||||
return { source: result[0], target: result[1], receipt: result[2] };
|
||||
}
|
||||
|
||||
export async function migrateAssetCatalogIndexedDB(
|
||||
database: IDBDatabase,
|
||||
options: {
|
||||
settingStore?: string;
|
||||
migrationStore?: string;
|
||||
faultAt?: AssetCatalogMigrationFault;
|
||||
} = {},
|
||||
): Promise<AssetCatalogIndexedDBMigrationResultIR> {
|
||||
const settingStore = options.settingStore ?? "setting";
|
||||
const migrationStore = options.migrationStore ?? "migration";
|
||||
assertStores(database, settingStore, migrationStore);
|
||||
|
||||
const before = await readAssetCatalogIndexedDBIndex(database, settingStore, migrationStore);
|
||||
if (!before.source) {
|
||||
if (!before.target || !before.receipt) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index has neither a complete v1 source nor a complete v2 migration");
|
||||
}
|
||||
const manifest = await parseAssetCatalogManifestV2(before.target.value);
|
||||
const receipt = parseReceipt(before.receipt);
|
||||
if (receipt.targetRevision !== manifest.revision || await sha256(manifest) !== receipt.targetManifestSha256) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog migration receipt does not match the stored v2 index");
|
||||
}
|
||||
return { status: "ALREADY_MIGRATED", manifest, receipt, migrationReport: null };
|
||||
}
|
||||
|
||||
if (before.target || before.receipt?.id === ASSET_CATALOG_MIGRATION_ID) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index contains a partial v1 to v2 migration");
|
||||
}
|
||||
|
||||
const prepared = await migrateAssetCatalogV1ToV2(before.source.value);
|
||||
const receipt: AssetCatalogIndexedDBMigrationReceiptIR = {
|
||||
schemaVersion: 1,
|
||||
id: ASSET_CATALOG_MIGRATION_ID,
|
||||
task: "M12-01G",
|
||||
status: "MIGRATED",
|
||||
sourceRevision: prepared.report.sourceRevision,
|
||||
targetRevision: prepared.report.targetRevision,
|
||||
sourceManifestSha256: prepared.report.sourceManifestSha256,
|
||||
targetManifestSha256: prepared.report.targetManifestSha256,
|
||||
};
|
||||
|
||||
let migrationFailure: Error | undefined;
|
||||
const transaction = database.transaction([settingStore, migrationStore], "readwrite");
|
||||
const completion = transactionComplete(transaction, () => migrationFailure);
|
||||
const settings = transaction.objectStore(settingStore);
|
||||
const migrations = transaction.objectStore(migrationStore);
|
||||
const sourceRequest = settings.get(ASSET_CATALOG_INDEX_V1_ID);
|
||||
sourceRequest.onerror = () => {
|
||||
migrationFailure = sourceRequest.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog source recheck failed");
|
||||
};
|
||||
sourceRequest.onsuccess = () => {
|
||||
const current = sourceRequest.result as StoredRow | undefined;
|
||||
if (!current || stableJSON(current.value) !== stableJSON(before.source?.value)) {
|
||||
migrationFailure = new AssetCatalogIndexedDBMigrationError("REVISION_CONFLICT", "Catalog v1 index changed while migration was prepared");
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
settings.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: prepared.manifest } satisfies AssetCatalogIndexRowV2);
|
||||
if (options.faultAt === "AFTER_TARGET_PUT") {
|
||||
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after target write");
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
migrations.put(receipt);
|
||||
settings.delete(ASSET_CATALOG_INDEX_V1_ID);
|
||||
if (options.faultAt === "AFTER_SOURCE_DELETE") {
|
||||
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after source delete");
|
||||
transaction.abort();
|
||||
}
|
||||
};
|
||||
await completion;
|
||||
return { status: "MIGRATED", manifest: prepared.manifest, receipt, migrationReport: prepared.report };
|
||||
}
|
||||
|
||||
export async function loadAssetCatalogIndexedDBSnapshot(
|
||||
database: IDBDatabase,
|
||||
options: { settingStore?: string; migrationStore?: string } = {},
|
||||
): Promise<AssetCatalogIndexedDBSnapshotIR> {
|
||||
const loaded = await migrateAssetCatalogIndexedDB(database, options);
|
||||
const manifestSha256 = await sha256(loaded.manifest);
|
||||
if (manifestSha256 !== loaded.receipt.targetManifestSha256) {
|
||||
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Loaded catalog index does not match its migration receipt");
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: loaded.manifest.revision,
|
||||
manifestSha256,
|
||||
catalogOrder: loaded.manifest.catalogs.map(({ catalogId, path }) => ({ catalogId, path })),
|
||||
assetOrder: loaded.manifest.assets.map(({ assetId }) => assetId),
|
||||
assetIdentities: loaded.manifest.assets.map(({ assetId, assetLibraryIdentifier, relativeAssetIdentifier }) => ({
|
||||
assetId,
|
||||
assetLibraryIdentifier,
|
||||
relativeAssetIdentifier,
|
||||
})),
|
||||
};
|
||||
}
|
||||
262
web/app/src/storage/asset-preview-opfs-commit.ts
Normal file
262
web/app/src/storage/asset-preview-opfs-commit.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogManifestV2IR,
|
||||
type AssetCatalogV2PreviewIR,
|
||||
} from "../../../protocol/asset-catalog-v2";
|
||||
import { planAssetPreviewDecode } from "../../../protocol/asset-preview-decode";
|
||||
import {
|
||||
createAssetPreviewIdentity,
|
||||
parseAssetPreviewIdentity,
|
||||
type AssetPreviewIdentityIR,
|
||||
} from "../../../protocol/asset-preview";
|
||||
import type { ErrorCode } from "../../../protocol/error";
|
||||
import {
|
||||
ASSET_CATALOG_INDEX_V2_ID,
|
||||
type AssetCatalogIndexRowV2,
|
||||
} from "./asset-catalog-indexeddb";
|
||||
import { readContentAsset, writeContentAsset } from "./opfs-files";
|
||||
|
||||
export const ASSET_PREVIEW_CATALOG_HEAD_ID = "asset-catalog:preview-head:v1" as const;
|
||||
export { createAssetPreviewIdentity, parseAssetCatalogManifestV2 };
|
||||
|
||||
export type AssetPreviewCatalogCommitFault =
|
||||
| "BEFORE_OPFS_WRITE"
|
||||
| "AFTER_OPFS_WRITE"
|
||||
| "AFTER_CATALOG_PUT";
|
||||
|
||||
export interface AssetPreviewCatalogCommitReceiptIR {
|
||||
schemaVersion: 1;
|
||||
id: typeof ASSET_PREVIEW_CATALOG_HEAD_ID;
|
||||
task: "M12-02D";
|
||||
status: "COMMITTED";
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
baseRevision: number;
|
||||
committedRevision: number;
|
||||
baseManifestSha256: string;
|
||||
committedManifestSha256: string;
|
||||
previewIdentitySha256: string;
|
||||
contentSha256: string;
|
||||
contentByteLength: number;
|
||||
opfsPath: string;
|
||||
}
|
||||
|
||||
export interface AssetPreviewCatalogCommitResultIR {
|
||||
manifest: AssetCatalogManifestV2IR;
|
||||
receipt: AssetPreviewCatalogCommitReceiptIR;
|
||||
preview: AssetCatalogV2PreviewIR;
|
||||
deduplicated: boolean;
|
||||
}
|
||||
|
||||
export class AssetPreviewCatalogCommitError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(`${code}: ${message}`, options);
|
||||
this.name = "AssetPreviewCatalogCommitError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: unknown): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(stableJSON(value));
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256Bytes(value: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", `Catalog row lookup failed for ${id}`));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction failed"));
|
||||
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
async function readCatalog(database: IDBDatabase, settingStore: string): Promise<{ row: StoredRow; manifest: AssetCatalogManifestV2IR }> {
|
||||
if (!database.objectStoreNames.contains(settingStore)) {
|
||||
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
|
||||
}
|
||||
const transaction = database.transaction(settingStore, "readonly");
|
||||
const row = await readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
|
||||
await transactionComplete(transaction, () => undefined);
|
||||
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
|
||||
return { row, manifest: await parseAssetCatalogManifestV2(row.value) };
|
||||
}
|
||||
|
||||
function parseReceipt(value: unknown): AssetPreviewCatalogCommitReceiptIR {
|
||||
const digest = /^[a-f0-9]{64}$/;
|
||||
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_PREVIEW_CATALOG_HEAD_ID ||
|
||||
value.task !== "M12-02D" || value.status !== "COMMITTED" || typeof value.projectId !== "string" ||
|
||||
typeof value.assetId !== "string" || !Number.isSafeInteger(value.baseRevision) || Number(value.baseRevision) < 0 ||
|
||||
!Number.isSafeInteger(value.committedRevision) || Number(value.committedRevision) !== Number(value.baseRevision) + 1 ||
|
||||
typeof value.baseManifestSha256 !== "string" || !digest.test(value.baseManifestSha256) ||
|
||||
typeof value.committedManifestSha256 !== "string" || !digest.test(value.committedManifestSha256) ||
|
||||
typeof value.previewIdentitySha256 !== "string" || !digest.test(value.previewIdentitySha256) ||
|
||||
typeof value.contentSha256 !== "string" || !digest.test(value.contentSha256) ||
|
||||
!Number.isSafeInteger(value.contentByteLength) || Number(value.contentByteLength) <= 0 ||
|
||||
typeof value.opfsPath !== "string" || value.opfsPath.length === 0) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is invalid");
|
||||
}
|
||||
return value as unknown as AssetPreviewCatalogCommitReceiptIR;
|
||||
}
|
||||
|
||||
function previewFromIdentity(identity: AssetPreviewIdentityIR): AssetCatalogV2PreviewIR {
|
||||
return {
|
||||
sha256: identity.content.sha256,
|
||||
mimeType: identity.content.mimeType,
|
||||
width: identity.content.width,
|
||||
height: identity.content.height,
|
||||
byteLength: identity.content.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitAssetPreviewToOPFS(
|
||||
database: IDBDatabase,
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
identityValue: unknown,
|
||||
encodedBytes: ArrayBuffer,
|
||||
options: {
|
||||
settingStore?: string;
|
||||
storage?: StorageManager;
|
||||
faultAt?: AssetPreviewCatalogCommitFault;
|
||||
} = {},
|
||||
): Promise<AssetPreviewCatalogCommitResultIR> {
|
||||
const settingStore = options.settingStore ?? "setting";
|
||||
const identity = await parseAssetPreviewIdentity(identityValue);
|
||||
await planAssetPreviewDecode(identity, encodedBytes);
|
||||
const before = await readCatalog(database, settingStore);
|
||||
if (before.manifest.revision !== expectedRevision) {
|
||||
throw new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog revision changed before preview storage");
|
||||
}
|
||||
const assetIndex = before.manifest.assets.findIndex((asset) => asset.assetId === identity.assetId);
|
||||
if (assetIndex === -1) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview identity does not belong to a catalog asset");
|
||||
}
|
||||
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_BUDGET_EXCEEDED", "Catalog revision cannot advance safely");
|
||||
}
|
||||
const preview = previewFromIdentity(identity);
|
||||
const candidate = await parseAssetCatalogManifestV2({
|
||||
...before.manifest,
|
||||
revision: expectedRevision + 1,
|
||||
assets: before.manifest.assets.map((asset, index) => index === assetIndex ? { ...asset, preview } : asset),
|
||||
});
|
||||
const baseManifestSha256 = await sha256(before.manifest);
|
||||
const committedManifestSha256 = await sha256(candidate);
|
||||
|
||||
if (options.faultAt === "BEFORE_OPFS_WRITE") {
|
||||
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure before preview OPFS write");
|
||||
}
|
||||
let written: Awaited<ReturnType<typeof writeContentAsset>>;
|
||||
try {
|
||||
written = await writeContentAsset(projectId, identity.content.sha256, encodedBytes, options.storage);
|
||||
const persisted = await readContentAsset(projectId, identity.content.sha256, options.storage);
|
||||
await planAssetPreviewDecode(identity, persisted);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof AssetPreviewCatalogCommitError || (error instanceof Error && "code" in error)) throw error;
|
||||
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview OPFS write or readback verification failed", { cause: error });
|
||||
}
|
||||
if (options.faultAt === "AFTER_OPFS_WRITE") {
|
||||
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview OPFS write");
|
||||
}
|
||||
|
||||
const receipt: AssetPreviewCatalogCommitReceiptIR = {
|
||||
schemaVersion: 1,
|
||||
id: ASSET_PREVIEW_CATALOG_HEAD_ID,
|
||||
task: "M12-02D",
|
||||
status: "COMMITTED",
|
||||
projectId,
|
||||
assetId: identity.assetId,
|
||||
baseRevision: expectedRevision,
|
||||
committedRevision: candidate.revision,
|
||||
baseManifestSha256,
|
||||
committedManifestSha256,
|
||||
previewIdentitySha256: identity.identitySha256,
|
||||
contentSha256: identity.content.sha256,
|
||||
contentByteLength: identity.content.byteLength,
|
||||
opfsPath: written.path,
|
||||
};
|
||||
|
||||
let commitFailure: Error | undefined;
|
||||
const transaction = database.transaction(settingStore, "readwrite");
|
||||
const completion = transactionComplete(transaction, () => commitFailure);
|
||||
const store = transaction.objectStore(settingStore);
|
||||
const request = store.get(ASSET_CATALOG_INDEX_V2_ID);
|
||||
request.onerror = () => {
|
||||
commitFailure = request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog source recheck failed");
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const current = request.result as StoredRow | undefined;
|
||||
if (!current || stableJSON(current.value) !== stableJSON(before.row.value)) {
|
||||
commitFailure = new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog index changed while preview storage was prepared");
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: candidate } satisfies AssetCatalogIndexRowV2);
|
||||
store.put(receipt);
|
||||
if (options.faultAt === "AFTER_CATALOG_PUT") {
|
||||
commitFailure = new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview catalog writes");
|
||||
transaction.abort();
|
||||
}
|
||||
};
|
||||
await completion;
|
||||
return { manifest: candidate, receipt, preview, deduplicated: written.deduplicated };
|
||||
}
|
||||
|
||||
export async function loadCommittedAssetPreviewCatalog(
|
||||
database: IDBDatabase,
|
||||
options: { settingStore?: string; storage?: StorageManager } = {},
|
||||
): Promise<AssetPreviewCatalogCommitResultIR> {
|
||||
const settingStore = options.settingStore ?? "setting";
|
||||
const catalog = await readCatalog(database, settingStore);
|
||||
const transaction = database.transaction(settingStore, "readonly");
|
||||
const row = await readRow(transaction.objectStore(settingStore), ASSET_PREVIEW_CATALOG_HEAD_ID);
|
||||
await transactionComplete(transaction, () => undefined);
|
||||
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is missing");
|
||||
const receipt = parseReceipt(row);
|
||||
if (catalog.manifest.revision !== receipt.committedRevision || await sha256(catalog.manifest) !== receipt.committedManifestSha256) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog receipt does not match the current catalog");
|
||||
}
|
||||
const asset = catalog.manifest.assets.find((candidate) => candidate.assetId === receipt.assetId);
|
||||
if (!asset?.preview || asset.preview.sha256 !== receipt.contentSha256 || asset.preview.byteLength !== receipt.contentByteLength) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Committed catalog preview does not match its receipt");
|
||||
}
|
||||
const persisted = await readContentAsset(receipt.projectId, receipt.contentSha256, options.storage);
|
||||
if (persisted.byteLength !== receipt.contentByteLength || await sha256Bytes(persisted) !== receipt.contentSha256) {
|
||||
throw new AssetPreviewCatalogCommitError("ASSET_SOURCE_HASH_MISMATCH", "Committed preview payload identity changed");
|
||||
}
|
||||
return { manifest: catalog.manifest, receipt, preview: asset.preview, deduplicated: false };
|
||||
}
|
||||
226
web/app/src/storage/asset-preview-quarantine.ts
Normal file
226
web/app/src/storage/asset-preview-quarantine.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogV2EntryIR,
|
||||
type AssetCatalogV2PreviewIR,
|
||||
} from "../../../protocol/asset-catalog-v2";
|
||||
import type { ErrorCode } from "../../../protocol/error";
|
||||
import { ASSET_CATALOG_INDEX_V2_ID } from "./asset-catalog-indexeddb";
|
||||
import { projectLayout } from "./opfs-files";
|
||||
|
||||
export const ASSET_PREVIEW_QUARANTINE_SCHEMA = 1 as const;
|
||||
export const ASSET_PREVIEW_QUARANTINE_PREFIX = "asset-preview:quarantine:v1:" as const;
|
||||
|
||||
export interface AssetPreviewQuarantineReceiptIR {
|
||||
schemaVersion: typeof ASSET_PREVIEW_QUARANTINE_SCHEMA;
|
||||
id: string;
|
||||
task: "M12-02F";
|
||||
status: "QUARANTINED";
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
catalogRevision: number;
|
||||
expected: AssetCatalogV2PreviewIR;
|
||||
actualByteLength: number | null;
|
||||
actualSha256: string | null;
|
||||
code: "ASSET_SOURCE_HASH_MISMATCH";
|
||||
quarantinePath: string | null;
|
||||
}
|
||||
|
||||
export type AssetPreviewInspectionIR =
|
||||
| {
|
||||
status: "READY";
|
||||
asset: AssetCatalogV2EntryIR;
|
||||
preview: AssetCatalogV2PreviewIR;
|
||||
data: ArrayBuffer;
|
||||
receipt: null;
|
||||
}
|
||||
| {
|
||||
status: "QUARANTINED";
|
||||
asset: AssetCatalogV2EntryIR;
|
||||
preview: null;
|
||||
data: null;
|
||||
receipt: AssetPreviewQuarantineReceiptIR;
|
||||
};
|
||||
|
||||
export class AssetPreviewQuarantineError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(`${code}: ${message}`, options);
|
||||
this.name = "AssetPreviewQuarantineError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256Bytes(value: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction failed"));
|
||||
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", `Preview quarantine lookup failed for ${id}`));
|
||||
});
|
||||
}
|
||||
|
||||
function receiptId(assetId: string): string {
|
||||
return `${ASSET_PREVIEW_QUARANTINE_PREFIX}${assetId}`;
|
||||
}
|
||||
|
||||
function parseReceipt(value: unknown, asset: AssetCatalogV2EntryIR, projectId: string, revision: number): AssetPreviewQuarantineReceiptIR | null {
|
||||
const digest = /^[a-f0-9]{64}$/;
|
||||
if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_QUARANTINE_SCHEMA || value.id !== receiptId(asset.assetId) ||
|
||||
value.task !== "M12-02F" || value.status !== "QUARANTINED" || value.projectId !== projectId ||
|
||||
value.assetId !== asset.assetId || value.catalogRevision !== revision || value.code !== "ASSET_SOURCE_HASH_MISMATCH" ||
|
||||
!record(value.expected) || !asset.preview || stableJSON(value.expected) !== stableJSON(asset.preview) ||
|
||||
(value.actualByteLength !== null && (!Number.isSafeInteger(value.actualByteLength) || Number(value.actualByteLength) < 0)) ||
|
||||
(value.actualSha256 !== null && (typeof value.actualSha256 !== "string" || !digest.test(value.actualSha256))) ||
|
||||
(value.quarantinePath !== null && (typeof value.quarantinePath !== "string" || value.quarantinePath.length === 0))) {
|
||||
return null;
|
||||
}
|
||||
return value as unknown as AssetPreviewQuarantineReceiptIR;
|
||||
}
|
||||
|
||||
async function directory(root: FileSystemDirectoryHandle, path: string, create = false): Promise<FileSystemDirectoryHandle> {
|
||||
let current = root;
|
||||
for (const segment of path.split("/")) current = await current.getDirectoryHandle(segment, { create });
|
||||
return current;
|
||||
}
|
||||
|
||||
async function readPayload(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer | null> {
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview inspection");
|
||||
try {
|
||||
const layout = projectLayout(projectId);
|
||||
const source = await directory(await manager.getDirectory(), `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
|
||||
return (await (await source.getFileHandle(sha256)).getFile()).arrayBuffer();
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantinePayload(
|
||||
projectId: string,
|
||||
expectedSha256: string,
|
||||
data: ArrayBuffer | null,
|
||||
actualSha256: string | null,
|
||||
storage?: StorageManager,
|
||||
): Promise<string | null> {
|
||||
if (!data || !actualSha256) return null;
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview quarantine");
|
||||
const layout = projectLayout(projectId);
|
||||
const root = await manager.getDirectory();
|
||||
const source = await directory(root, `${layout.assetsPath}/sha256/${expectedSha256.slice(0, 2)}`);
|
||||
const quarantine = await directory(root, `${layout.assetsPath}/quarantine`, true);
|
||||
const name = `${expectedSha256}.${actualSha256.slice(0, 12)}.corrupt`;
|
||||
const handle = await quarantine.getFileHandle(name, { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
const copied = await (await handle.getFile()).arrayBuffer();
|
||||
if (copied.byteLength !== data.byteLength || await sha256Bytes(copied) !== actualSha256) {
|
||||
await quarantine.removeEntry(name).catch(() => undefined);
|
||||
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine copy verification failed");
|
||||
}
|
||||
await source.removeEntry(expectedSha256);
|
||||
return `${layout.assetsPath}/quarantine/${name}`;
|
||||
}
|
||||
|
||||
export async function inspectAssetPreviewWithQuarantine(
|
||||
database: IDBDatabase,
|
||||
projectId: string,
|
||||
assetId: string,
|
||||
options: { settingStore?: string; storage?: StorageManager } = {},
|
||||
): Promise<AssetPreviewInspectionIR> {
|
||||
const settingStore = options.settingStore ?? "setting";
|
||||
if (!database.objectStoreNames.contains(settingStore)) {
|
||||
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
|
||||
}
|
||||
const readTransaction = database.transaction(settingStore, "readonly");
|
||||
const store = readTransaction.objectStore(settingStore);
|
||||
const [catalogRow, quarantineRow] = await Promise.all([
|
||||
readRow(store, ASSET_CATALOG_INDEX_V2_ID),
|
||||
readRow(store, receiptId(assetId)),
|
||||
]);
|
||||
await transactionComplete(readTransaction, () => undefined);
|
||||
if (!catalogRow) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
|
||||
const manifest = await parseAssetCatalogManifestV2(catalogRow.value);
|
||||
const asset = manifest.assets.find((candidate) => candidate.assetId === assetId);
|
||||
if (!asset) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Preview asset is not in the catalog");
|
||||
if (!asset.preview) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog asset has no preview reference");
|
||||
const existingReceipt = parseReceipt(quarantineRow, asset, projectId, manifest.revision);
|
||||
if (existingReceipt) return { status: "QUARANTINED", asset, preview: null, data: null, receipt: existingReceipt };
|
||||
|
||||
const data = await readPayload(projectId, asset.preview.sha256, options.storage);
|
||||
const actualSha256 = data ? await sha256Bytes(data) : null;
|
||||
if (data && data.byteLength === asset.preview.byteLength && actualSha256 === asset.preview.sha256) {
|
||||
return { status: "READY", asset, preview: asset.preview, data, receipt: null };
|
||||
}
|
||||
const quarantinePath = await quarantinePayload(projectId, asset.preview.sha256, data, actualSha256, options.storage);
|
||||
const receipt: AssetPreviewQuarantineReceiptIR = {
|
||||
schemaVersion: ASSET_PREVIEW_QUARANTINE_SCHEMA,
|
||||
id: receiptId(asset.assetId),
|
||||
task: "M12-02F",
|
||||
status: "QUARANTINED",
|
||||
projectId,
|
||||
assetId: asset.assetId,
|
||||
catalogRevision: manifest.revision,
|
||||
expected: asset.preview,
|
||||
actualByteLength: data?.byteLength ?? null,
|
||||
actualSha256,
|
||||
code: "ASSET_SOURCE_HASH_MISMATCH",
|
||||
quarantinePath,
|
||||
};
|
||||
|
||||
let failure: Error | undefined;
|
||||
const transaction = database.transaction(settingStore, "readwrite");
|
||||
const completion = transactionComplete(transaction, () => failure);
|
||||
const writeStore = transaction.objectStore(settingStore);
|
||||
const request = writeStore.get(ASSET_CATALOG_INDEX_V2_ID);
|
||||
request.onerror = () => {
|
||||
failure = request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog recheck failed during preview quarantine");
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const current = request.result as StoredRow | undefined;
|
||||
if (!current || stableJSON(current.value) !== stableJSON(catalogRow.value)) {
|
||||
failure = new AssetPreviewQuarantineError("REVISION_CONFLICT", "Catalog changed while corrupt preview was quarantined");
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
writeStore.put(receipt);
|
||||
};
|
||||
await completion;
|
||||
return { status: "QUARANTINED", asset, preview: null, data: null, receipt };
|
||||
}
|
||||
184
web/app/src/storage/asset-preview-reference-gc.ts
Normal file
184
web/app/src/storage/asset-preview-reference-gc.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogManifestV2IR,
|
||||
type AssetCatalogV2EntryIR,
|
||||
type AssetCatalogV2PreviewIR,
|
||||
} from "../../../protocol/asset-catalog-v2";
|
||||
import type { ErrorCode } from "../../../protocol/error";
|
||||
import { ASSET_CATALOG_INDEX_V2_ID, type AssetCatalogIndexRowV2 } from "./asset-catalog-indexeddb";
|
||||
import { ASSET_PREVIEW_CATALOG_HEAD_ID } from "./asset-preview-opfs-commit";
|
||||
import { deleteContentAsset } from "./opfs-files";
|
||||
|
||||
export const ASSET_PREVIEW_REFERENCE_GC_PREFIX = "asset-preview:reference-gc:v1:" as const;
|
||||
|
||||
export interface AssetPreviewReferenceGCReceiptIR {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
task: "M12-02G";
|
||||
status: "RETAINED" | "COLLECTED";
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
baseRevision: number;
|
||||
committedRevision: number;
|
||||
baseManifestSha256: string;
|
||||
committedManifestSha256: string;
|
||||
removedPreview: AssetCatalogV2PreviewIR;
|
||||
remainingProjectReferences: number;
|
||||
payloadReclaimed: boolean;
|
||||
}
|
||||
|
||||
export interface AssetPreviewReferenceGCResultIR {
|
||||
manifest: AssetCatalogManifestV2IR;
|
||||
asset: AssetCatalogV2EntryIR;
|
||||
receipt: AssetPreviewReferenceGCReceiptIR;
|
||||
}
|
||||
|
||||
export class AssetPreviewReferenceGCError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(`${code}: ${message}`, options);
|
||||
this.name = "AssetPreviewReferenceGCError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: unknown): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(stableJSON(value)));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function receiptId(assetId: string): string {
|
||||
return `${ASSET_PREVIEW_REFERENCE_GC_PREFIX}${assetId}`;
|
||||
}
|
||||
|
||||
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", `Preview reference lookup failed for ${id}`));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference transaction failed"));
|
||||
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCatalog(database: IDBDatabase, settingStore: string): Promise<{ row: StoredRow; manifest: AssetCatalogManifestV2IR }> {
|
||||
if (!database.objectStoreNames.contains(settingStore)) {
|
||||
throw new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
|
||||
}
|
||||
const transaction = database.transaction(settingStore, "readonly");
|
||||
const row = await readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
|
||||
await transactionComplete(transaction, () => undefined);
|
||||
if (!row) throw new AssetPreviewReferenceGCError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
|
||||
return { row, manifest: await parseAssetCatalogManifestV2(row.value) };
|
||||
}
|
||||
|
||||
export async function removeAssetPreviewReference(
|
||||
database: IDBDatabase,
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
assetId: string,
|
||||
options: { settingStore?: string; storage?: StorageManager } = {},
|
||||
): Promise<AssetPreviewReferenceGCResultIR> {
|
||||
const settingStore = options.settingStore ?? "setting";
|
||||
const before = await loadCatalog(database, settingStore);
|
||||
if (before.manifest.revision !== expectedRevision) {
|
||||
throw new AssetPreviewReferenceGCError("REVISION_CONFLICT", "Catalog revision changed before preview reference removal");
|
||||
}
|
||||
const assetIndex = before.manifest.assets.findIndex((asset) => asset.assetId === assetId);
|
||||
const sourceAsset = before.manifest.assets[assetIndex];
|
||||
if (assetIndex === -1 || !sourceAsset?.preview) {
|
||||
throw new AssetPreviewReferenceGCError("ASSET_MANIFEST_INVALID", "Catalog asset has no removable preview reference");
|
||||
}
|
||||
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
|
||||
throw new AssetPreviewReferenceGCError("ASSET_BUDGET_EXCEEDED", "Catalog revision cannot advance safely");
|
||||
}
|
||||
const removedPreview = sourceAsset.preview;
|
||||
const candidate = await parseAssetCatalogManifestV2({
|
||||
...before.manifest,
|
||||
revision: expectedRevision + 1,
|
||||
assets: before.manifest.assets.map((asset, index) => index === assetIndex ? { ...asset, preview: null } : asset),
|
||||
});
|
||||
const remainingProjectReferences = candidate.assets.filter((asset) => asset.preview?.sha256 === removedPreview.sha256).length;
|
||||
const baseManifestSha256 = await sha256(before.manifest);
|
||||
const committedManifestSha256 = await sha256(candidate);
|
||||
const pendingReceipt: AssetPreviewReferenceGCReceiptIR = {
|
||||
schemaVersion: 1,
|
||||
id: receiptId(assetId),
|
||||
task: "M12-02G",
|
||||
status: "RETAINED",
|
||||
projectId,
|
||||
assetId,
|
||||
baseRevision: expectedRevision,
|
||||
committedRevision: candidate.revision,
|
||||
baseManifestSha256,
|
||||
committedManifestSha256,
|
||||
removedPreview,
|
||||
remainingProjectReferences,
|
||||
payloadReclaimed: false,
|
||||
};
|
||||
|
||||
let commitFailure: Error | undefined;
|
||||
const transaction = database.transaction(settingStore, "readwrite");
|
||||
const completion = transactionComplete(transaction, () => commitFailure);
|
||||
const store = transaction.objectStore(settingStore);
|
||||
const request = store.get(ASSET_CATALOG_INDEX_V2_ID);
|
||||
request.onerror = () => {
|
||||
commitFailure = request.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Catalog recheck failed during preview reference removal");
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const current = request.result as StoredRow | undefined;
|
||||
if (!current || stableJSON(current.value) !== stableJSON(before.row.value)) {
|
||||
commitFailure = new AssetPreviewReferenceGCError("REVISION_CONFLICT", "Catalog changed while preview reference removal was prepared");
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: candidate } satisfies AssetCatalogIndexRowV2);
|
||||
store.delete(ASSET_PREVIEW_CATALOG_HEAD_ID);
|
||||
store.put(pendingReceipt);
|
||||
};
|
||||
await completion;
|
||||
|
||||
const payloadReclaimed = remainingProjectReferences === 0;
|
||||
if (payloadReclaimed) {
|
||||
try {
|
||||
await deleteContentAsset(projectId, removedPreview.sha256, options.storage);
|
||||
}
|
||||
catch (error) {
|
||||
throw new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference was removed but payload reclamation failed", { cause: error });
|
||||
}
|
||||
}
|
||||
const receipt: AssetPreviewReferenceGCReceiptIR = {
|
||||
...pendingReceipt,
|
||||
status: payloadReclaimed ? "COLLECTED" : "RETAINED",
|
||||
payloadReclaimed,
|
||||
};
|
||||
const receiptTransaction = database.transaction(settingStore, "readwrite");
|
||||
receiptTransaction.objectStore(settingStore).put(receipt);
|
||||
await transactionComplete(receiptTransaction, () => undefined);
|
||||
return { manifest: candidate, asset: candidate.assets[assetIndex], receipt };
|
||||
}
|
||||
351
web/app/src/testing/render-compositor-media-recovery.ts
Normal file
351
web/app/src/testing/render-compositor-media-recovery.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
parseRenderCompositorMediaRecoverySuite,
|
||||
type RenderCompositorMediaRecoveryEvidenceIR,
|
||||
} from "../../../protocol/render-compositor-media-recovery";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { createGPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { WebEngineClient } from "../engine-client/WebEngineClient";
|
||||
import { ViewportRenderer } from "../three-adapter/viewport";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import {
|
||||
CompositorFrameCache,
|
||||
CompositorValidationError,
|
||||
executeCompositorGraph,
|
||||
executeCompositorGraphCached,
|
||||
type CompositorGraphIR,
|
||||
type CompositorImageBuffer,
|
||||
} from "../compositor/CompositorExecutor";
|
||||
import { createSequencerCodecProbeRequest, probeSequencerCodec } from "../sequencer/SequencerCodecProbe";
|
||||
import { generateInitialSequencerMovieProxyFrame, SequencerMediaProxyCache } from "../sequencer/SequencerMediaProxyCache";
|
||||
|
||||
export interface RenderCompositorMediaRecoveryInput {
|
||||
renderBlend: ArrayBuffer;
|
||||
compositorBlend: ArrayBuffer;
|
||||
movie: {
|
||||
mimeType: string;
|
||||
data: ArrayBuffer;
|
||||
};
|
||||
texturePng: ArrayBuffer;
|
||||
}
|
||||
|
||||
function stable(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
||||
const source = value as Record<string, unknown>;
|
||||
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${stable(source[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
async function sha256(value: string | ArrayBuffer): Promise<string> {
|
||||
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function code(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
|
||||
if (error instanceof Error) return error.message.split(":", 1)[0];
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function requireInput(value: ArrayBuffer, label: string): ArrayBuffer {
|
||||
if (!(value instanceof ArrayBuffer) || value.byteLength === 0) throw new Error(`M11_RECOVERY_INPUT_INVALID: ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderIdentity(snapshot: SceneSnapshotIR): Promise<string> {
|
||||
return sha256(stable({
|
||||
revision: snapshot.revision,
|
||||
sceneId: snapshot.sceneId,
|
||||
activeObjectId: snapshot.activeObjectId,
|
||||
nodes: snapshot.nodes,
|
||||
meshes: snapshot.meshes,
|
||||
materials: snapshot.materials,
|
||||
cameras: snapshot.cameras,
|
||||
lights: snapshot.lights,
|
||||
worlds: snapshot.worlds,
|
||||
scenes: snapshot.scenes,
|
||||
}));
|
||||
}
|
||||
|
||||
async function renderSnapshot(
|
||||
snapshot: SceneSnapshotIR,
|
||||
geometryBuffers: Parameters<ViewportRenderer["setSnapshot"]>[1],
|
||||
nonMeshGeometryBuffers: Parameters<ViewportRenderer["setSnapshot"]>[2],
|
||||
): Promise<{ sha256: string; byteLength: number; visibleUnits: number }> {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
canvas.style.position = "fixed";
|
||||
canvas.style.left = "-10000px";
|
||||
document.body.append(canvas);
|
||||
const renderer = new ViewportRenderer(canvas);
|
||||
try {
|
||||
const renderable = structuredClone(snapshot);
|
||||
renderable.activeObjectId = null;
|
||||
renderer.setSnapshot(renderable, geometryBuffers ?? [], nonMeshGeometryBuffers ?? []);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const gl = renderer.renderer.getContext();
|
||||
const width = gl.drawingBufferWidth;
|
||||
const height = gl.drawingBufferHeight;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let visibleUnits = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset] + pixels[offset + 1] + pixels[offset + 2] > 12) visibleUnits++;
|
||||
}
|
||||
return { sha256: await sha256(pixels.buffer), byteLength: pixels.byteLength, visibleUnits };
|
||||
}
|
||||
finally {
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function runRenderRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const blend = requireInput(input.renderBlend, "renderBlend");
|
||||
const texturePng = requireInput(input.texturePng, "texturePng");
|
||||
const sourceSha256 = await sha256(blend);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
let released = { releasedTextures: 0, releasedPayloadBytes: 0, releasedGPUBytes: 0, resourcesAfter: 0 as const };
|
||||
try {
|
||||
const first = await client.openBlend(blend.slice(0));
|
||||
const identityBefore = await renderIdentity(first.snapshot);
|
||||
const outputBefore = await renderSnapshot(first.snapshot, first.geometryBuffers, first.nonMeshGeometryBuffers);
|
||||
|
||||
const cancellation = new AbortController();
|
||||
cancellation.abort();
|
||||
let cancellationCode = "";
|
||||
try { await client.openBlend(blend.slice(0), undefined, cancellation.signal); }
|
||||
catch (error) { cancellationCode = code(error); }
|
||||
const afterCancellation = await client.snapshot();
|
||||
if (await renderIdentity(afterCancellation.snapshot) !== identityBefore) {
|
||||
throw new Error("M11_RENDER_CANCELLATION_MUTATED_STATE");
|
||||
}
|
||||
|
||||
await client.restart();
|
||||
const second = await client.openBlend(blend.slice(0));
|
||||
const identityAfter = await renderIdentity(second.snapshot);
|
||||
const outputAfter = await renderSnapshot(second.snapshot, second.geometryBuffers, second.nonMeshGeometryBuffers);
|
||||
|
||||
const textureAsset = await createGPUTextureAsset({
|
||||
assetId: "asset:m11-recovery-texture",
|
||||
imageId: "image:m11-recovery-texture",
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
usage: "BASE_COLOR",
|
||||
colorSpace: "SRGB",
|
||||
}, texturePng.slice(0));
|
||||
const accepted = await textureStore.upload([textureAsset]);
|
||||
if (accepted.loaded !== 1) throw new Error("M11_RENDER_TEXTURE_UPLOAD_FAILED");
|
||||
const retainedBeforeBudget = textureStore.stats();
|
||||
const overflow = Array.from({ length: 257 }, (_, index) => ({
|
||||
...textureAsset,
|
||||
assetId: `asset:m11-recovery-overflow:${index}`,
|
||||
imageId: `image:m11-recovery-overflow:${index}`,
|
||||
data: textureAsset.data.slice(0),
|
||||
}));
|
||||
const blocked = await textureStore.upload(overflow);
|
||||
const retainedAfterBudget = textureStore.stats();
|
||||
if (stable(retainedBeforeBudget) !== stable(retainedAfterBudget)) {
|
||||
throw new Error("M11_RENDER_BUDGET_MUTATED_TEXTURES");
|
||||
}
|
||||
released = textureStore.dispose();
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "RENDER",
|
||||
source: { byteLength: blend.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "OPEN_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore.sha256,
|
||||
outputSha256After: outputAfter.sha256,
|
||||
},
|
||||
budget: {
|
||||
status: "BLOCKED",
|
||||
code: blocked.errorCodes[0] as "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
retainedIdentityHash: identityAfter,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
release: {
|
||||
status: "RELEASED",
|
||||
releasedBytes: released.releasedPayloadBytes,
|
||||
releasedResources: released.releasedTextures,
|
||||
resourcesAfter: released.resourcesAfter,
|
||||
},
|
||||
recovery: {
|
||||
status: "RECOVERED",
|
||||
identityHash: identityAfter,
|
||||
outputSha256: outputAfter.sha256,
|
||||
outputBytes: outputAfter.byteLength,
|
||||
visibleUnits: outputAfter.visibleUnits,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (released.releasedTextures === 0) textureStore.dispose();
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
function compositorGraph(snapshot: SceneSnapshotIR): CompositorGraphIR {
|
||||
const graph = snapshot.scenes.find((scene) => scene.name === "M11 Chain")?.compositorGraph;
|
||||
if (!graph) throw new Error("M11_COMPOSITOR_GRAPH_MISSING");
|
||||
return graph;
|
||||
}
|
||||
|
||||
async function compositorOutput(image: CompositorImageBuffer): Promise<{ sha256: string; byteLength: number; visibleUnits: number }> {
|
||||
const bytes = image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer;
|
||||
let visibleUnits = 0;
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
if (Math.abs(image.data[offset]) + Math.abs(image.data[offset + 1]) + Math.abs(image.data[offset + 2]) > 0) visibleUnits++;
|
||||
}
|
||||
return { sha256: await sha256(bytes), byteLength: bytes.byteLength, visibleUnits };
|
||||
}
|
||||
|
||||
async function runCompositorRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const blend = requireInput(input.compositorBlend, "compositorBlend");
|
||||
const sourceSha256 = await sha256(blend);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const firstCache = new CompositorFrameCache(1024);
|
||||
const secondCache = new CompositorFrameCache(1024);
|
||||
try {
|
||||
const first = await client.openBlend(blend.slice(0));
|
||||
const firstGraph = compositorGraph(first.snapshot);
|
||||
const identityBefore = await sha256(stable(firstGraph));
|
||||
const firstResult = await executeCompositorGraphCached(firstGraph, new Map(), firstCache, { frame: 1, width: 2, height: 2 });
|
||||
const outputBefore = await compositorOutput(firstResult.composite);
|
||||
|
||||
let cancellationCode = "";
|
||||
try { executeCompositorGraph(firstGraph, new Map(), { width: 2, height: 2, cancelled: () => true }); }
|
||||
catch (error) { cancellationCode = error instanceof CompositorValidationError ? error.code : code(error); }
|
||||
|
||||
const cacheBytesBeforeBudget = firstCache.byteLength;
|
||||
let budgetCode = "";
|
||||
try { await executeCompositorGraphCached(firstGraph, new Map(), firstCache, { frame: 2, width: 8_193, height: 1 }); }
|
||||
catch (error) { budgetCode = error instanceof CompositorValidationError ? error.code : code(error); }
|
||||
if (firstCache.byteLength !== cacheBytesBeforeBudget) throw new Error("M11_COMPOSITOR_BUDGET_MUTATED_CACHE");
|
||||
|
||||
await client.restart();
|
||||
const second = await client.openBlend(blend.slice(0));
|
||||
const secondGraph = compositorGraph(second.snapshot);
|
||||
const identityAfter = await sha256(stable(secondGraph));
|
||||
const secondResult = await executeCompositorGraphCached(secondGraph, new Map(), secondCache, { frame: 1, width: 2, height: 2 });
|
||||
const outputAfter = await compositorOutput(secondResult.composite);
|
||||
|
||||
const releasedResources = firstCache.size;
|
||||
const releasedBytes = firstCache.clear();
|
||||
secondCache.clear();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "COMPOSITOR",
|
||||
source: { byteLength: blend.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "COMPOSITOR_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore.sha256,
|
||||
outputSha256After: outputAfter.sha256,
|
||||
},
|
||||
budget: { status: "BLOCKED", code: budgetCode as "COMPOSITOR_BUDGET_EXCEEDED", retainedIdentityHash: identityAfter, temporaryResourcesAfter: 0 },
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: {
|
||||
status: "RECOVERED",
|
||||
identityHash: identityAfter,
|
||||
outputSha256: outputAfter.sha256,
|
||||
outputBytes: outputAfter.byteLength,
|
||||
visibleUnits: outputAfter.visibleUnits,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
firstCache.clear();
|
||||
secondCache.clear();
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMediaRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const movie = requireInput(input.movie.data, "movie.data");
|
||||
const sourceSha256 = await sha256(movie);
|
||||
const request = createSequencerCodecProbeRequest("MOVIE", input.movie.mimeType, movie.byteLength, sourceSha256);
|
||||
const capability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const profile = { kind: "MOVIE_RGBA8_FRAME" as const, width: 8, height: 8, colorSpace: "SRGB8" as const, alphaMode: "STRAIGHT" as const };
|
||||
const first = await generateInitialSequencerMovieProxyFrame(request, capability, profile, movie.slice(0));
|
||||
const identityBefore = first.manifest.identitySha256;
|
||||
const outputBefore = await sha256(first.data);
|
||||
|
||||
const cancellation = new AbortController();
|
||||
cancellation.abort();
|
||||
let cancellationCode = "";
|
||||
try { await generateInitialSequencerMovieProxyFrame(request, capability, profile, movie.slice(0), cancellation.signal); }
|
||||
catch (error) { cancellationCode = code(error); }
|
||||
|
||||
const firstCache = new SequencerMediaProxyCache(first.data.byteLength);
|
||||
await firstCache.put(first.manifest, first.data.slice(0), request, capability);
|
||||
const blockedCache = new SequencerMediaProxyCache(first.data.byteLength - 1);
|
||||
let budgetCode = "";
|
||||
try { await blockedCache.put(first.manifest, first.data.slice(0), request, capability); }
|
||||
catch (error) { budgetCode = code(error); }
|
||||
if (blockedCache.stats().bytes !== 0) throw new Error("M11_MEDIA_BUDGET_MUTATED_CACHE");
|
||||
|
||||
const releasedResources = firstCache.stats().entries;
|
||||
const releasedBytes = firstCache.clear();
|
||||
const secondCapability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const second = await generateInitialSequencerMovieProxyFrame(request, secondCapability, profile, movie.slice(0));
|
||||
const secondCache = new SequencerMediaProxyCache(second.data.byteLength);
|
||||
try {
|
||||
await secondCache.put(second.manifest, second.data.slice(0), request, secondCapability);
|
||||
const reopened = await secondCache.get(request, secondCapability, profile, 0);
|
||||
if (!reopened) throw new Error("M11_MEDIA_RESTART_CACHE_MISS");
|
||||
const identityAfter = reopened.manifest.identitySha256;
|
||||
const outputAfter = await sha256(reopened.data);
|
||||
let visibleUnits = 0;
|
||||
const pixels = new Uint8Array(reopened.data);
|
||||
for (let offset = 3; offset < pixels.length; offset += 4) if (pixels[offset] > 0) visibleUnits++;
|
||||
secondCache.clear();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "MEDIA",
|
||||
source: { byteLength: movie.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "SEQUENCER_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore,
|
||||
outputSha256After: outputAfter,
|
||||
},
|
||||
budget: { status: "BLOCKED", code: budgetCode as "SEQUENCER_BUDGET_EXCEEDED", retainedIdentityHash: identityAfter, temporaryResourcesAfter: 0 },
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: { status: "RECOVERED", identityHash: identityAfter, outputSha256: outputAfter, outputBytes: reopened.data.byteLength, visibleUnits },
|
||||
};
|
||||
}
|
||||
finally {
|
||||
firstCache.clear();
|
||||
blockedCache.clear();
|
||||
secondCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRenderCompositorMediaRecoverySuite(
|
||||
input: RenderCompositorMediaRecoveryInput,
|
||||
): Promise<RenderCompositorMediaRecoveryEvidenceIR[]> {
|
||||
const reports = [
|
||||
await runRenderRecovery(input),
|
||||
await runCompositorRecovery(input),
|
||||
await runMediaRecovery(input),
|
||||
];
|
||||
return parseRenderCompositorMediaRecoverySuite(reports);
|
||||
}
|
||||
@@ -22,6 +22,20 @@ export interface TextureUploadStatus {
|
||||
budget: PBRTextureBudgetReport;
|
||||
}
|
||||
|
||||
export interface GPUTextureStoreStatsIR {
|
||||
entries: number;
|
||||
payloadBytes: number;
|
||||
decodedGPUBytes: number;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface GPUTextureStoreReleaseIR {
|
||||
releasedTextures: number;
|
||||
releasedPayloadBytes: number;
|
||||
releasedGPUBytes: number;
|
||||
resourcesAfter: 0;
|
||||
}
|
||||
|
||||
function key(imageId: string, usage: GPUTextureUsage): string {
|
||||
return `${imageId}:${usage}`;
|
||||
}
|
||||
@@ -67,6 +81,16 @@ export class GPUTextureStore {
|
||||
return this.assets.get(key(imageId, usage));
|
||||
}
|
||||
|
||||
stats(): GPUTextureStoreStatsIR {
|
||||
const assets = [...this.assets.values()];
|
||||
return {
|
||||
entries: this.textures.size,
|
||||
payloadBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
|
||||
decodedGPUBytes: assets.reduce((total, asset) => total + asset.width * asset.height * 4, 0),
|
||||
keys: [...this.textures.keys()].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
async upload(assets: readonly GPUTextureAsset[]): Promise<TextureUploadStatus> {
|
||||
const candidate = new Map(this.assets);
|
||||
for (const asset of assets) {
|
||||
@@ -161,10 +185,17 @@ export class GPUTextureStore {
|
||||
return true;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
dispose(): GPUTextureStoreReleaseIR {
|
||||
const before = this.stats();
|
||||
for (const texture of this.textures.values()) texture.dispose();
|
||||
this.textures.clear();
|
||||
this.assets.clear();
|
||||
this.udimTileCounts.clear();
|
||||
return {
|
||||
releasedTextures: before.entries,
|
||||
releasedPayloadBytes: before.payloadBytes,
|
||||
releasedGPUBytes: before.decodedGPUBytes,
|
||||
resourcesAfter: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
26
web/app/src/workers/asset-catalog-restart-test.worker.ts
Normal file
26
web/app/src/workers/asset-catalog-restart-test.worker.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { loadAssetCatalogIndexedDBSnapshot } from "../storage/asset-catalog-indexeddb";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<{ databaseName: string }>) => void) | null;
|
||||
postMessage: (value: unknown) => void;
|
||||
};
|
||||
|
||||
function openDatabase(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("Catalog restart database open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
void openDatabase(event.data.databaseName).then(async (database) => {
|
||||
try {
|
||||
return await loadAssetCatalogIndexedDBSnapshot(database);
|
||||
}
|
||||
finally {
|
||||
database.close();
|
||||
}
|
||||
}).then((snapshot) => scope.postMessage({ ok: true, snapshot }))
|
||||
.catch((error) => scope.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
13
web/app/src/workers/asset-preview-display-test.worker.ts
Normal file
13
web/app/src/workers/asset-preview-display-test.worker.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { displayAssetPreview } from "../assets/AssetPreviewDisplay";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<{ identity: unknown; bytes: ArrayBuffer }>) => void) | null;
|
||||
postMessage: (value: unknown, transfer?: Transferable[]) => void;
|
||||
};
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
const canvas = new OffscreenCanvas(1, 1);
|
||||
void displayAssetPreview(canvas, "OFFSCREEN_CANVAS_2D", event.data.identity, event.data.bytes)
|
||||
.then((result) => scope.postMessage({ ok: true, receipt: result.receipt, pixels: result.pixels }, [result.pixels.buffer]))
|
||||
.catch((error) => scope.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
gatePaintPBVHCapability,
|
||||
PAINT_PBVH_WASM_ENTRYPOINT,
|
||||
} from "../../../protocol/paint-pbvh-capability";
|
||||
import {
|
||||
createLibraryMainAppendReceipt,
|
||||
parseLibraryMainAppendRequest,
|
||||
sha256LibraryBytes,
|
||||
type LibraryAppendClosureIR,
|
||||
} from "../../../protocol/library-main-append";
|
||||
|
||||
type WasmModule = {
|
||||
_malloc: (size: number) => number;
|
||||
@@ -39,6 +45,7 @@ type WasmModule = {
|
||||
_web_engine_get_allocated_bytes: () => number;
|
||||
_web_engine_open_blend: (handle: number, data: number, length: number) => number;
|
||||
_web_engine_apply_command: (handle: number, data: number, length: number) => number;
|
||||
_web_engine_append_library_object: (handle: number, source: number, sourceLength: number, metadata: number, metadataLength: number) => number;
|
||||
_web_engine_undo: (handle: number) => number;
|
||||
_web_engine_redo: (handle: number) => number;
|
||||
_web_engine_get_scene_snapshot: (handle: number, data: number, length: number) => number;
|
||||
@@ -603,6 +610,30 @@ function nativeErrorFor(target: WasmModule, fallbackCode: ErrorReport["code"]):
|
||||
return report(mappedCode, message);
|
||||
}
|
||||
|
||||
function assertAppendedClosure(
|
||||
snapshot: ReturnType<typeof parseSceneSnapshotIR>,
|
||||
closure: LibraryAppendClosureIR,
|
||||
): void {
|
||||
const name = (value: string) => value.slice(value.indexOf("/") + 1);
|
||||
const objectId = `object:${name(closure.object)}`;
|
||||
const meshId = `mesh:${name(closure.mesh)}`;
|
||||
const materialId = `material:${name(closure.material)}`;
|
||||
const imageId = `image:${name(closure.image)}`;
|
||||
const object = snapshot.nodes.find((item) => item.id === objectId);
|
||||
const mesh = snapshot.meshes.find((item) => item.id === meshId);
|
||||
const material = snapshot.materials.find((item) => item.id === materialId);
|
||||
const image = snapshot.images.find((item) => item.id === imageId);
|
||||
const materialImageIds = new Set([
|
||||
...(material?.imageIds ?? []),
|
||||
...(material?.nodes?.flatMap((node) => node.imageId ? [node.imageId] : []) ?? []),
|
||||
]);
|
||||
if (!object || object.type !== "MESH" || object.dataId !== meshId || !mesh ||
|
||||
mesh.materialSlotIds?.length !== 1 || mesh.materialSlotIds[0] !== materialId ||
|
||||
!material || !materialImageIds.has(imageId) || !image || image.libraryLinked !== false) {
|
||||
throw report("ASSET_SOURCE_HASH_MISMATCH", "WASM Main append did not publish the expected fully-local dependency closure");
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize(): Promise<WebEngineStatus> {
|
||||
if (module) return status();
|
||||
if (!initializationPromise) {
|
||||
@@ -1316,6 +1347,55 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "appendLibraryObject": {
|
||||
await initialize();
|
||||
const beforeSnapshot = currentSnapshot ?? (await readSnapshotResult()).snapshot;
|
||||
let appendRequest;
|
||||
try {
|
||||
appendRequest = await parseLibraryMainAppendRequest(request.command.request, beforeSnapshot.revision);
|
||||
const sourceSha256 = await sha256LibraryBytes(request.command.source);
|
||||
if (sourceSha256 !== appendRequest.binding.source.sourceSha256) {
|
||||
throw report("ASSET_SOURCE_HASH_MISMATCH", "library append bytes do not match the bound source SHA-256");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "ASSET_MANIFEST_INVALID");
|
||||
}
|
||||
const metadata = {
|
||||
schemaVersion: appendRequest.schemaVersion,
|
||||
baseRevision: appendRequest.baseRevision,
|
||||
sourceLocator: appendRequest.binding.source.sourceLocator,
|
||||
sourceDataBlockId: appendRequest.binding.sourceDataBlockId,
|
||||
expectedClosure: appendRequest.expectedClosure,
|
||||
};
|
||||
const sourceInput = copyIntoWasm(request.command.source);
|
||||
const metadataInput = copyCommand(metadata);
|
||||
try {
|
||||
if (!module || module._web_engine_append_library_object(
|
||||
handle,
|
||||
sourceInput.pointer,
|
||||
sourceInput.length,
|
||||
metadataInput.pointer,
|
||||
metadataInput.length,
|
||||
) !== 0) {
|
||||
throw nativeError("ASSET_MANIFEST_INVALID");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
module?._free(metadataInput.pointer);
|
||||
module?._free(sourceInput.pointer);
|
||||
}
|
||||
const scene = await readSnapshotResult();
|
||||
assertAppendedClosure(scene.snapshot, appendRequest.expectedClosure);
|
||||
currentSnapshot = scene.snapshot;
|
||||
result = {
|
||||
status: status(),
|
||||
...publishIncrementalScene(scene),
|
||||
delta: readDelta(),
|
||||
libraryAppend: createLibraryMainAppendReceipt(appendRequest, scene.snapshot.revision),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "generateLOD": {
|
||||
await initialize();
|
||||
const payload = request.command.payload;
|
||||
|
||||
Reference in New Issue
Block a user