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;
|
||||
|
||||
@@ -30,7 +30,7 @@ if(WEB_ENGINE_THREADS)
|
||||
set(WEB_ENGINE_EXPORTED_RUNTIME_METHODS "['ccall','cwrap','PThread']")
|
||||
endif()
|
||||
target_link_options(web_engine PRIVATE
|
||||
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_get_scene_snapshot','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message']"
|
||||
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_append_library_object','_web_engine_get_scene_snapshot','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message']"
|
||||
"-sEXPORTED_RUNTIME_METHODS=${WEB_ENGINE_EXPORTED_RUNTIME_METHODS}"
|
||||
)
|
||||
set_target_properties(web_engine PROPERTIES OUTPUT_NAME "web_engine")
|
||||
|
||||
@@ -61,6 +61,21 @@ bool web_engine_blend_main_create_primitive(WebBlendMainState *,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_append_object(WebBlendMainState *,
|
||||
const uint8_t *,
|
||||
uint32_t,
|
||||
const char *,
|
||||
const char *,
|
||||
const char *,
|
||||
const char *,
|
||||
const char *,
|
||||
std::string &,
|
||||
std::string &error)
|
||||
{
|
||||
error = "native smoke Main stub";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_duplicate_object(WebBlendMainState *,
|
||||
const char *,
|
||||
const std::vector<float> &,
|
||||
|
||||
@@ -121,8 +121,30 @@
|
||||
"test:sequencer-media-revision": "node --test tests/unit/sequencer-media-revision.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-media-revision.spec.ts",
|
||||
"test:sequencer-final-export": "node --test tests/unit/sequencer-final-export.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-final-export.spec.ts",
|
||||
"test:sequencer-audio-recovery": "node --test tests/unit/sequencer-audio-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-audio-recovery.spec.ts",
|
||||
"test:render-compositor-media-recovery": "node --test tests/unit/render-compositor-media-recovery.test.mjs && node ../tools/web/check-render-reference.mjs && node ../tools/web/check-compositor-node-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-compositor-media-recovery.spec.ts",
|
||||
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
|
||||
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
|
||||
"test:asset-catalog-inventory": "node ../tools/web/check-asset-catalog-field-inventory.mjs",
|
||||
"test:asset-catalog-v1-fixture": "node ../tools/web/check-asset-catalog-v1-fixture.mjs",
|
||||
"test:asset-catalog-v2": "node --test tests/unit/asset-catalog-v2.test.mjs",
|
||||
"test:asset-catalog-migration": "node --test tests/unit/asset-catalog-migration.test.mjs",
|
||||
"test:asset-catalog-legacy-reader": "node --test tests/unit/asset-catalog-compatibility.test.mjs",
|
||||
"test:asset-catalog-negatives": "node --test tests/unit/asset-catalog-negatives.test.mjs",
|
||||
"test:asset-catalog-indexeddb-migration": "node --test tests/unit/asset-catalog-migration.test.mjs tests/unit/asset-catalog-indexeddb-migration.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-catalog-indexeddb-migration.spec.ts",
|
||||
"test:asset-catalog-restart": "node --test tests/unit/asset-catalog-migration.test.mjs tests/unit/asset-catalog-indexeddb-migration.test.mjs tests/unit/asset-catalog-restart.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-catalog-indexeddb-migration.spec.ts tests/e2e/asset-catalog-restart.spec.ts",
|
||||
"test:asset-catalog-m12-evidence": "npm run test:asset-catalog-inventory && npm run test:asset-catalog-v1-fixture && npm run test:asset-catalog-v2 && npm run test:asset-catalog-migration && npm run test:asset-catalog-legacy-reader && npm run test:asset-catalog-negatives && npm run test:asset-catalog-restart && node ../tools/web/check-asset-catalog-m12-evidence.mjs",
|
||||
"test:asset-preview-inventory": "node ../tools/web/check-asset-preview-inventory.mjs",
|
||||
"test:asset-preview-identity": "node --test tests/unit/asset-preview-identity.test.mjs && node ../tools/web/check-asset-preview-identity.mjs",
|
||||
"test:asset-preview-decode-budget": "node --test tests/unit/asset-preview-identity.test.mjs tests/unit/asset-preview-decode.test.mjs",
|
||||
"test:asset-preview-opfs-commit": "node --test tests/unit/asset-preview-opfs-commit.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-preview-opfs-commit.spec.ts",
|
||||
"test:asset-preview-dedup": "node --test tests/unit/asset-preview-dedup.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-preview-dedup.spec.ts",
|
||||
"test:asset-preview-quarantine": "node --test tests/unit/asset-preview-quarantine.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-preview-quarantine.spec.ts",
|
||||
"test:asset-preview-reference-gc": "node --test tests/unit/asset-preview-reference-gc.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-preview-reference-gc.spec.ts",
|
||||
"test:asset-preview-display": "node --test tests/unit/asset-preview-display.test.mjs && node ../tools/web/check-asset-preview-display.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/asset-preview-display.spec.ts",
|
||||
"test:library-operation-inventory": "node ../tools/web/check-library-operation-inventory.mjs",
|
||||
"test:library-operation-identity": "node --test tests/unit/library-operation-identity.test.mjs",
|
||||
"test:library-append-desktop": "node ../tools/web/check-library-append-fixture.mjs",
|
||||
"test:library-main-append": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts",
|
||||
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
|
||||
"test:library-main-reader": "node ../tools/web/check-library-main-reader.mjs",
|
||||
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
|
||||
|
||||
161
web/protocol/asset-catalog-compatibility.ts
Normal file
161
web/protocol/asset-catalog-compatibility.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { parseAssetLibraryManifest } from "./asset-library-io";
|
||||
import { ASSET_CATALOG_SCHEMA, parseAssetCatalogManifestV2 } from "./asset-catalog-v2";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_COMPATIBILITY_SCHEMA = 1 as const;
|
||||
export type AssetCatalogLegacyOperation = "READ" | "CATALOG_WRITE" | "ASSET_WRITE" | "SAVE";
|
||||
|
||||
export interface AssetCatalogLegacySnapshotIR {
|
||||
revision: number;
|
||||
catalogs: Array<{ catalogId: string; path: string; simpleName: string }>;
|
||||
assets: Array<{ assetId: string; relativeAssetIdentifier: string; idType: string; name: string; catalogId: string | null }>;
|
||||
libraries: Array<{ libraryId: string; name: string; readOnly: boolean }>;
|
||||
}
|
||||
|
||||
export interface AssetCatalogLegacyCompatibilityIR {
|
||||
schemaVersion: typeof ASSET_CATALOG_COMPATIBILITY_SCHEMA;
|
||||
task: "M12-01E";
|
||||
readerSchemaVersion: 1;
|
||||
documentSchemaVersion: number;
|
||||
operation: AssetCatalogLegacyOperation;
|
||||
status: "READY" | "READ_ONLY" | "BLOCKED";
|
||||
code: ErrorCode | null;
|
||||
recoverable: boolean;
|
||||
sourceSha256: string;
|
||||
snapshot: AssetCatalogLegacySnapshotIR | null;
|
||||
nextTask: "M12-01F";
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
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", encoder.encode(stableJSON(value)));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function operation(value: unknown): AssetCatalogLegacyOperation {
|
||||
if (value !== "READ" && value !== "CATALOG_WRITE" && value !== "ASSET_WRITE" && value !== "SAVE") {
|
||||
throw new Error("ASSET_MANIFEST_INVALID: legacy reader operation is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function inspectAssetCatalogForLegacyReader(
|
||||
value: unknown,
|
||||
operationValue: unknown,
|
||||
): Promise<AssetCatalogLegacyCompatibilityIR> {
|
||||
const requestedOperation = operation(operationValue);
|
||||
const sourceSha256 = await sha256(value);
|
||||
if (!record(value) || typeof value.schemaVersion !== "number" || !Number.isSafeInteger(value.schemaVersion)) {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 0,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
recoverable: false,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
if (value.schemaVersion === 1) {
|
||||
try {
|
||||
parseAssetLibraryManifest(value);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 1,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "ASSET_MANIFEST_INVALID",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 1,
|
||||
operation: requestedOperation,
|
||||
status: "READY",
|
||||
code: null,
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
if (value.schemaVersion !== ASSET_CATALOG_SCHEMA) {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: value.schemaVersion,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
recoverable: false,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
|
||||
let document;
|
||||
try {
|
||||
document = await parseAssetCatalogManifestV2(value);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "ASSET_MANIFEST_INVALID",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
const snapshot: AssetCatalogLegacySnapshotIR = {
|
||||
revision: document.revision,
|
||||
catalogs: document.catalogs.map(({ catalogId, path, simpleName }) => ({ catalogId, path, simpleName })),
|
||||
assets: document.assets.map(({ assetId, relativeAssetIdentifier, idType, name, catalogId }) => ({ assetId, relativeAssetIdentifier, idType, name, catalogId })),
|
||||
libraries: document.libraries.map(({ libraryId, name, readOnly }) => ({ libraryId, name, readOnly })),
|
||||
};
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
operation: requestedOperation,
|
||||
status: requestedOperation === "READ" ? "READ_ONLY" : "BLOCKED",
|
||||
code: "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
260
web/protocol/asset-catalog-migration.ts
Normal file
260
web/protocol/asset-catalog-migration.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { parseAssetLibraryManifest } from "./asset-library-io";
|
||||
import {
|
||||
ASSET_CATALOG_SCHEMA,
|
||||
createAssetCatalogV2StableId,
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogManifestV2IR,
|
||||
type AssetCatalogV2IDType,
|
||||
} from "./asset-catalog-v2";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_MIGRATION_SCHEMA = 1 as const;
|
||||
export const ASSET_CATALOG_V1_UUID_NAMESPACE = "d15c0cb7-e77a-5d56-b7da-f7d3ecb31df1" as const;
|
||||
|
||||
export interface AssetCatalogMigrationReportIR {
|
||||
schemaVersion: typeof ASSET_CATALOG_MIGRATION_SCHEMA;
|
||||
task: "M12-01D";
|
||||
status: "MIGRATED";
|
||||
sourceSchemaVersion: 1;
|
||||
targetSchemaVersion: typeof ASSET_CATALOG_SCHEMA;
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
sourceManifestSha256: string;
|
||||
targetManifestSha256: string;
|
||||
catalogMappings: Array<{ legacyId: string; catalogId: string; path: string }>;
|
||||
assetMappings: Array<{ legacyId: string; assetId: string; relativeAssetIdentifier: string }>;
|
||||
defaultsApplied: {
|
||||
dynamicMetadataFields: number;
|
||||
preferredImportMethods: number;
|
||||
customPropertyCollections: number;
|
||||
};
|
||||
preserved: {
|
||||
catalogs: number;
|
||||
assets: number;
|
||||
libraries: number;
|
||||
previews: number;
|
||||
sourceBindings: number;
|
||||
};
|
||||
nextTask: "M12-01E";
|
||||
}
|
||||
|
||||
export interface AssetCatalogMigrationResultIR {
|
||||
manifest: AssetCatalogManifestV2IR;
|
||||
report: AssetCatalogMigrationReportIR;
|
||||
}
|
||||
|
||||
export class AssetCatalogMigrationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetCatalogMigrationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const encoder = new TextEncoder();
|
||||
const ID_TYPE_DIRECTORY: Readonly<Record<AssetCatalogV2IDType, string>> = Object.freeze({
|
||||
ACTION: "Action",
|
||||
COLLECTION: "Collection",
|
||||
IMAGE: "Image",
|
||||
MATERIAL: "Material",
|
||||
NODE_GROUP: "NodeTree",
|
||||
OBJECT: "Object",
|
||||
WORLD: "World",
|
||||
});
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `${path} contains fields schema v1 cannot migrate losslessly`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLegacyShape(value: unknown): asserts value is Record<string, unknown> {
|
||||
if (!record(value)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", "schema v1 manifest must be an object");
|
||||
exactKeys(value, ["schemaVersion", "revision", "catalogs", "assets", "libraries"], "manifest");
|
||||
if (!Array.isArray(value.catalogs) || !Array.isArray(value.assets) || !Array.isArray(value.libraries)) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", "schema v1 manifest collections are invalid");
|
||||
}
|
||||
value.catalogs.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalogs[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "parentId"], `catalogs[${index}]`);
|
||||
});
|
||||
value.assets.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "kind", "catalogId", "tags", "author", "license", "sourceSha256", "sourcePath", "preview"], `assets[${index}]`);
|
||||
if (item.preview !== undefined) {
|
||||
if (!record(item.preview)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}].preview must be an object`);
|
||||
exactKeys(item.preview, ["assetId", "sha256", "mimeType", "width", "height", "byteLength"], `assets[${index}].preview`);
|
||||
if (item.preview.assetId !== item.id) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}].preview belongs to another asset`);
|
||||
}
|
||||
});
|
||||
value.libraries.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `libraries[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "sourcePath", "sourceSha256", "dependencyIds", "readOnly"], `libraries[${index}]`);
|
||||
});
|
||||
}
|
||||
|
||||
function uuidBytes(value: string): Uint8Array {
|
||||
return Uint8Array.from(value.replaceAll("-", "").match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16));
|
||||
}
|
||||
|
||||
function formatUUID(value: Uint8Array): string {
|
||||
const hex = Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
async function catalogUUID(legacyId: string): Promise<string> {
|
||||
if (UUID.test(legacyId)) return legacyId;
|
||||
const namespace = uuidBytes(ASSET_CATALOG_V1_UUID_NAMESPACE);
|
||||
const name = encoder.encode(legacyId);
|
||||
const input = new Uint8Array(namespace.byteLength + name.byteLength);
|
||||
input.set(namespace);
|
||||
input.set(name, namespace.byteLength);
|
||||
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", input)).slice(0, 16);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
return formatUUID(bytes);
|
||||
}
|
||||
|
||||
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", encoder.encode(stableJSON(value)));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function migrateAssetCatalogV1ToV2(value: unknown): Promise<AssetCatalogMigrationResultIR> {
|
||||
validateLegacyShape(value);
|
||||
let legacy;
|
||||
try {
|
||||
legacy = parseAssetLibraryManifest(value);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const legacyCatalogById = new Map(legacy.catalogs.map((catalog) => [catalog.id, catalog]));
|
||||
const pathCache = new Map<string, string>();
|
||||
const resolvePath = (id: string): string => {
|
||||
const cached = pathCache.get(id);
|
||||
if (cached) return cached;
|
||||
const catalog = legacyCatalogById.get(id);
|
||||
if (!catalog) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalog ${id} is missing`);
|
||||
if (catalog.name.includes("/") || catalog.name === "." || catalog.name === ".." || catalog.name.trim() !== catalog.name) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalog ${id} name cannot be represented as a Blender path component`);
|
||||
}
|
||||
const result = catalog.parentId === null ? catalog.name : `${resolvePath(catalog.parentId)}/${catalog.name}`;
|
||||
pathCache.set(id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const catalogMappings = await Promise.all(legacy.catalogs.map(async (catalog) => ({
|
||||
legacyId: catalog.id,
|
||||
catalogId: await catalogUUID(catalog.id),
|
||||
path: resolvePath(catalog.id),
|
||||
})));
|
||||
const mappingByLegacyId = new Map(catalogMappings.map((mapping) => [mapping.legacyId, mapping]));
|
||||
const catalogs = catalogMappings.map((mapping) => ({
|
||||
catalogId: mapping.catalogId,
|
||||
path: mapping.path,
|
||||
simpleName: legacyCatalogById.get(mapping.legacyId)?.name ?? "",
|
||||
parentPath: mapping.path.includes("/") ? mapping.path.slice(0, mapping.path.lastIndexOf("/")) : null,
|
||||
})).sort((left, right) => compareText(left.path, right.path) || compareText(left.catalogId, right.catalogId));
|
||||
|
||||
const assetMappings: AssetCatalogMigrationReportIR["assetMappings"] = [];
|
||||
const assets = await Promise.all(legacy.assets.map(async (asset) => {
|
||||
const idType = asset.kind as AssetCatalogV2IDType;
|
||||
const relativeAssetIdentifier = `${ID_TYPE_DIRECTORY[idType]}/${asset.name}`;
|
||||
const assetId = await createAssetCatalogV2StableId({ assetLibraryIdentifier: null, relativeAssetIdentifier });
|
||||
assetMappings.push({ legacyId: asset.id, assetId, relativeAssetIdentifier });
|
||||
const mappedCatalog = asset.catalogId === null ? null : mappingByLegacyId.get(asset.catalogId);
|
||||
return {
|
||||
assetId,
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier,
|
||||
idType,
|
||||
name: asset.name,
|
||||
catalogId: mappedCatalog?.catalogId ?? null,
|
||||
catalogSimpleName: asset.catalogId === null ? "" : legacyCatalogById.get(asset.catalogId)?.name ?? "",
|
||||
author: asset.author,
|
||||
description: "",
|
||||
copyright: "",
|
||||
license: asset.license,
|
||||
tags: [...asset.tags],
|
||||
activeTag: 0,
|
||||
usePreferredImportMethod: false,
|
||||
preferredImportMethod: "APPEND" as const,
|
||||
customProperties: [],
|
||||
sourceSha256: asset.sourceSha256,
|
||||
sourcePath: asset.sourcePath ?? null,
|
||||
preview: asset.preview ? {
|
||||
sha256: asset.preview.sha256,
|
||||
mimeType: asset.preview.mimeType,
|
||||
width: asset.preview.width,
|
||||
height: asset.preview.height,
|
||||
byteLength: asset.preview.byteLength,
|
||||
} : null,
|
||||
};
|
||||
}));
|
||||
assets.sort((left, right) => compareText(left.assetId, right.assetId));
|
||||
assetMappings.sort((left, right) => compareText(left.legacyId, right.legacyId));
|
||||
|
||||
const libraries = legacy.libraries.map((library) => ({
|
||||
libraryId: library.id,
|
||||
name: library.name,
|
||||
sourcePath: library.sourcePath,
|
||||
sourceSha256: library.sourceSha256,
|
||||
dependencyIds: [...library.dependencyIds],
|
||||
readOnly: library.readOnly,
|
||||
})).sort((left, right) => compareText(left.libraryId, right.libraryId));
|
||||
const manifest = await parseAssetCatalogManifestV2({
|
||||
schemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
revision: legacy.revision,
|
||||
catalogs,
|
||||
assets,
|
||||
libraries,
|
||||
});
|
||||
const report: AssetCatalogMigrationReportIR = {
|
||||
schemaVersion: ASSET_CATALOG_MIGRATION_SCHEMA,
|
||||
task: "M12-01D",
|
||||
status: "MIGRATED",
|
||||
sourceSchemaVersion: 1,
|
||||
targetSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
sourceRevision: legacy.revision,
|
||||
targetRevision: manifest.revision,
|
||||
sourceManifestSha256: await sha256(legacy),
|
||||
targetManifestSha256: await sha256(manifest),
|
||||
catalogMappings: [...catalogMappings].sort((left, right) => compareText(left.legacyId, right.legacyId)),
|
||||
assetMappings,
|
||||
defaultsApplied: {
|
||||
dynamicMetadataFields: legacy.assets.length * 2,
|
||||
preferredImportMethods: legacy.assets.length,
|
||||
customPropertyCollections: legacy.assets.length,
|
||||
},
|
||||
preserved: {
|
||||
catalogs: legacy.catalogs.length,
|
||||
assets: legacy.assets.length,
|
||||
libraries: legacy.libraries.length,
|
||||
previews: legacy.assets.filter((asset) => asset.preview !== undefined).length,
|
||||
sourceBindings: legacy.assets.length,
|
||||
},
|
||||
nextTask: "M12-01E",
|
||||
};
|
||||
return { manifest, report };
|
||||
}
|
||||
474
web/protocol/asset-catalog-v2.ts
Normal file
474
web/protocol/asset-catalog-v2.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_SCHEMA = 2 as const;
|
||||
export const ASSET_CATALOG_V2_BUDGET = Object.freeze({
|
||||
maxCatalogs: 10_000,
|
||||
maxAssets: 100_000,
|
||||
maxLibraries: 1_024,
|
||||
maxDependenciesPerLibrary: 1_024,
|
||||
maxCatalogPathBytes: 4_096,
|
||||
maxCatalogSimpleNameBytes: 63,
|
||||
maxLibraryIdentifierBytes: 1_024,
|
||||
maxRelativeAssetIdentifierBytes: 4_096,
|
||||
maxAssetNameBytes: 1_024,
|
||||
maxMetadataTextBytes: 64 * 1024,
|
||||
maxTagBytes: 63,
|
||||
maxTagsPerAsset: 128,
|
||||
maxCustomPropertiesPerAsset: 256,
|
||||
maxCustomPropertyNameBytes: 63,
|
||||
maxCustomPropertyStringBytes: 64 * 1024,
|
||||
maxCustomPropertyArrayItems: 4_096,
|
||||
maxTotalTextBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
|
||||
export const ASSET_CATALOG_V2_ID_TYPES = ["ACTION", "COLLECTION", "IMAGE", "MATERIAL", "NODE_GROUP", "OBJECT", "WORLD"] as const;
|
||||
export type AssetCatalogV2IDType = typeof ASSET_CATALOG_V2_ID_TYPES[number];
|
||||
export type AssetCatalogV2ImportMethod = "LINK" | "APPEND" | "PACK";
|
||||
export type AssetCatalogV2CustomProperty =
|
||||
| { name: string; type: "BOOL"; value: boolean }
|
||||
| { name: string; type: "INT"; value: number }
|
||||
| { name: string; type: "FLOAT"; value: number }
|
||||
| { name: string; type: "STRING"; value: string }
|
||||
| { name: string; type: "INT_ARRAY"; value: number[] }
|
||||
| { name: string; type: "FLOAT_ARRAY"; value: number[] }
|
||||
| { name: string; type: "STRING_ARRAY"; value: string[] };
|
||||
|
||||
export interface AssetCatalogV2IR {
|
||||
catalogId: string;
|
||||
path: string;
|
||||
simpleName: string;
|
||||
parentPath: string | null;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2EntryIR {
|
||||
assetId: string;
|
||||
assetLibraryIdentifier: string | null;
|
||||
relativeAssetIdentifier: string;
|
||||
idType: AssetCatalogV2IDType;
|
||||
name: string;
|
||||
catalogId: string | null;
|
||||
catalogSimpleName: string;
|
||||
author: string;
|
||||
description: string;
|
||||
copyright: string;
|
||||
license: string;
|
||||
tags: string[];
|
||||
activeTag: number;
|
||||
usePreferredImportMethod: boolean;
|
||||
preferredImportMethod: AssetCatalogV2ImportMethod;
|
||||
customProperties: AssetCatalogV2CustomProperty[];
|
||||
sourceSha256: string | null;
|
||||
sourcePath: string | null;
|
||||
preview: AssetCatalogV2PreviewIR | null;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2PreviewIR {
|
||||
sha256: string;
|
||||
mimeType: "image/png" | "image/webp";
|
||||
width: number;
|
||||
height: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2LibraryIR {
|
||||
libraryId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
sourceSha256: string;
|
||||
dependencyIds: string[];
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface AssetCatalogManifestV2IR {
|
||||
schemaVersion: typeof ASSET_CATALOG_SCHEMA;
|
||||
revision: number;
|
||||
catalogs: AssetCatalogV2IR[];
|
||||
assets: AssetCatalogV2EntryIR[];
|
||||
libraries: AssetCatalogV2LibraryIR[];
|
||||
}
|
||||
|
||||
export class AssetCatalogV2ValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetCatalogV2ValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
const ASSET_ID = /^asset:[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
const ID_TYPE_DIRECTORY: Readonly<Record<AssetCatalogV2IDType, string>> = Object.freeze({
|
||||
ACTION: "Action",
|
||||
COLLECTION: "Collection",
|
||||
IMAGE: "Image",
|
||||
MATERIAL: "Material",
|
||||
NODE_GROUP: "NodeTree",
|
||||
OBJECT: "Object",
|
||||
WORLD: "World",
|
||||
});
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded integer range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface TextBudgetState { total: number }
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
name: string,
|
||||
maximumBytes: number,
|
||||
state: TextBudgetState,
|
||||
allowEmpty = false,
|
||||
): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be text`);
|
||||
}
|
||||
const bytes = encoder.encode(value).byteLength;
|
||||
if ((!allowEmpty && bytes === 0) || bytes > maximumBytes) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${name} exceeds its UTF-8 byte budget`);
|
||||
}
|
||||
state.total += bytes;
|
||||
if (!Number.isSafeInteger(state.total) || state.total > ASSET_CATALOG_V2_BUDGET.maxTotalTextBytes) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", "catalog manifest exceeds the aggregate text budget");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalUUID(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !UUID.test(value)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be a canonical RFC4122 UUID`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseCatalogPath(value: unknown, name: string, state: TextBudgetState): string {
|
||||
const result = boundedText(value, name, ASSET_CATALOG_V2_BUDGET.maxCatalogPathBytes, state);
|
||||
const components = result.split("/");
|
||||
if (components.some((component) => component.length === 0 || component === "." || component === ".." || component.trim() !== component)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} is not a cleaned Blender catalog path`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectedParentPath(path: string): string | null {
|
||||
const separator = path.lastIndexOf("/");
|
||||
return separator === -1 ? null : path.slice(0, separator);
|
||||
}
|
||||
|
||||
function parseCatalog(value: unknown, index: number, state: TextBudgetState): AssetCatalogV2IR {
|
||||
const name = `catalogs[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be an object`);
|
||||
exactKeys(value, ["catalogId", "path", "simpleName", "parentPath"], name);
|
||||
const path = parseCatalogPath(value.path, `${name}.path`, state);
|
||||
const parentPath = value.parentPath === null ? null : parseCatalogPath(value.parentPath, `${name}.parentPath`, state);
|
||||
if (parentPath !== expectedParentPath(path)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name}.parentPath does not match the Blender path hierarchy`);
|
||||
}
|
||||
return {
|
||||
catalogId: canonicalUUID(value.catalogId, `${name}.catalogId`),
|
||||
path,
|
||||
simpleName: boundedText(value.simpleName, `${name}.simpleName`, ASSET_CATALOG_V2_BUDGET.maxCatalogSimpleNameBytes, state),
|
||||
parentPath,
|
||||
};
|
||||
}
|
||||
|
||||
function digest(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePreview(value: unknown, path: string): AssetCatalogV2PreviewIR {
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["sha256", "mimeType", "width", "height", "byteLength"], path);
|
||||
if (value.mimeType !== "image/png" && value.mimeType !== "image/webp") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.mimeType is unsupported`);
|
||||
}
|
||||
return {
|
||||
sha256: digest(value.sha256, `${path}.sha256`),
|
||||
mimeType: value.mimeType,
|
||||
width: boundedInteger(value.width, `${path}.width`, 1, 4_096),
|
||||
height: boundedInteger(value.height, `${path}.height`, 1, 4_096),
|
||||
byteLength: boundedInteger(value.byteLength, `${path}.byteLength`, 1, 64 * 1024 * 1024),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLibrary(value: unknown, index: number, state: TextBudgetState): AssetCatalogV2LibraryIR {
|
||||
const path = `libraries[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["libraryId", "name", "sourcePath", "sourceSha256", "dependencyIds", "readOnly"], path);
|
||||
if (!Array.isArray(value.dependencyIds) || value.dependencyIds.length > ASSET_CATALOG_V2_BUDGET.maxDependenciesPerLibrary) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.dependencyIds exceeds the entry budget`);
|
||||
}
|
||||
if (typeof value.readOnly !== "boolean") throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.readOnly must be boolean`);
|
||||
return {
|
||||
libraryId: boundedText(value.libraryId, `${path}.libraryId`, ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes, state),
|
||||
name: boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxAssetNameBytes, state),
|
||||
sourcePath: boundedText(value.sourcePath, `${path}.sourcePath`, ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes, state),
|
||||
sourceSha256: digest(value.sourceSha256, `${path}.sourceSha256`),
|
||||
dependencyIds: value.dependencyIds.map((item, dependencyIndex) => boundedText(item, `${path}.dependencyIds[${dependencyIndex}]`, ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes, state)),
|
||||
readOnly: value.readOnly,
|
||||
};
|
||||
}
|
||||
|
||||
function assertLibraryGraph(libraries: readonly AssetCatalogV2LibraryIR[]): void {
|
||||
const byId = new Map(libraries.map((library) => [library.libraryId, library]));
|
||||
if (byId.size !== libraries.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate library IDs");
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (id: string): void => {
|
||||
if (active.has(id)) throw new AssetCatalogV2ValidationError("LIBRARY_DEPENDENCY_CYCLE", `library dependency cycle includes ${id}`);
|
||||
if (complete.has(id)) return;
|
||||
const library = byId.get(id);
|
||||
if (!library) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `library dependency ${id} is missing`);
|
||||
active.add(id);
|
||||
for (const dependency of library.dependencyIds) visit(dependency);
|
||||
active.delete(id);
|
||||
complete.add(id);
|
||||
};
|
||||
for (const id of byId.keys()) visit(id);
|
||||
}
|
||||
|
||||
function parseCustomProperty(value: unknown, path: string, state: TextBudgetState): AssetCatalogV2CustomProperty {
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["name", "type", "value"], path);
|
||||
const name = boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyNameBytes, state);
|
||||
switch (value.type) {
|
||||
case "BOOL":
|
||||
if (typeof value.value !== "boolean") throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must be boolean`);
|
||||
return { name, type: "BOOL", value: value.value };
|
||||
case "INT":
|
||||
return { name, type: "INT", value: boundedInteger(value.value, `${path}.value`, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER) };
|
||||
case "FLOAT":
|
||||
if (typeof value.value !== "number" || !Number.isFinite(value.value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must be finite`);
|
||||
return { name, type: "FLOAT", value: value.value };
|
||||
case "STRING":
|
||||
return { name, type: "STRING", value: boundedText(value.value, `${path}.value`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyStringBytes, state, true) };
|
||||
case "INT_ARRAY":
|
||||
case "FLOAT_ARRAY":
|
||||
case "STRING_ARRAY": {
|
||||
if (!Array.isArray(value.value) || value.value.length > ASSET_CATALOG_V2_BUDGET.maxCustomPropertyArrayItems) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.value exceeds the array budget`);
|
||||
}
|
||||
if (value.type === "STRING_ARRAY") {
|
||||
return { name, type: value.type, value: value.value.map((item, index) => boundedText(item, `${path}.value[${index}]`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyStringBytes, state, true)) };
|
||||
}
|
||||
if (value.type === "INT_ARRAY") {
|
||||
return { name, type: value.type, value: value.value.map((item, index) => boundedInteger(item, `${path}.value[${index}]`, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)) };
|
||||
}
|
||||
if (value.value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must contain finite numbers`);
|
||||
}
|
||||
return { name, type: value.type, value: [...value.value] as number[] };
|
||||
}
|
||||
default:
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.type is unsupported`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetIdentity(
|
||||
value: { assetLibraryIdentifier: unknown; relativeAssetIdentifier: unknown },
|
||||
state: TextBudgetState,
|
||||
path: string,
|
||||
): { assetLibraryIdentifier: string | null; relativeAssetIdentifier: string } {
|
||||
const assetLibraryIdentifier = value.assetLibraryIdentifier === null ? null : boundedText(
|
||||
value.assetLibraryIdentifier,
|
||||
`${path}.assetLibraryIdentifier`,
|
||||
ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes,
|
||||
state,
|
||||
);
|
||||
const relativeAssetIdentifier = boundedText(
|
||||
value.relativeAssetIdentifier,
|
||||
`${path}.relativeAssetIdentifier`,
|
||||
ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes,
|
||||
state,
|
||||
);
|
||||
return { assetLibraryIdentifier, relativeAssetIdentifier };
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function createAssetCatalogV2StableId(identityValue: unknown): Promise<string> {
|
||||
if (!record(identityValue)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "asset identity must be an object");
|
||||
exactKeys(identityValue, ["assetLibraryIdentifier", "relativeAssetIdentifier"], "asset identity");
|
||||
const identity = parseAssetIdentity({
|
||||
assetLibraryIdentifier: identityValue.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: identityValue.relativeAssetIdentifier,
|
||||
}, { total: 0 }, "asset identity");
|
||||
const canonical = JSON.stringify({
|
||||
schema: "BLENDER_ASSET_WEAK_REFERENCE_V1",
|
||||
assetLibraryIdentifier: identity.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: identity.relativeAssetIdentifier,
|
||||
});
|
||||
return `asset:${await sha256(canonical)}`;
|
||||
}
|
||||
|
||||
async function parseAsset(value: unknown, index: number, catalogIds: ReadonlySet<string>, state: TextBudgetState): Promise<AssetCatalogV2EntryIR> {
|
||||
const path = `assets[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, [
|
||||
"assetId", "assetLibraryIdentifier", "relativeAssetIdentifier", "idType", "name", "catalogId",
|
||||
"catalogSimpleName", "author", "description", "copyright", "license", "tags", "activeTag",
|
||||
"usePreferredImportMethod", "preferredImportMethod", "customProperties",
|
||||
"sourceSha256", "sourcePath", "preview",
|
||||
], path);
|
||||
if (!ASSET_CATALOG_V2_ID_TYPES.includes(value.idType as AssetCatalogV2IDType)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.idType is unsupported`);
|
||||
}
|
||||
const idType = value.idType as AssetCatalogV2IDType;
|
||||
const identity = parseAssetIdentity({
|
||||
assetLibraryIdentifier: value.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: value.relativeAssetIdentifier,
|
||||
}, state, path);
|
||||
if (!identity.relativeAssetIdentifier.startsWith(`${ID_TYPE_DIRECTORY[idType]}/`)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.relativeAssetIdentifier disagrees with idType`);
|
||||
}
|
||||
if (typeof value.assetId !== "string" || !ASSET_ID.test(value.assetId) || value.assetId !== await createAssetCatalogV2StableId(identity)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.assetId does not bind the Blender weak reference`);
|
||||
}
|
||||
const catalogId = value.catalogId === null ? null : canonicalUUID(value.catalogId, `${path}.catalogId`);
|
||||
if (catalogId !== null && !catalogIds.has(catalogId)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.catalogId is not declared`);
|
||||
}
|
||||
if (!Array.isArray(value.tags) || value.tags.length > ASSET_CATALOG_V2_BUDGET.maxTagsPerAsset) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.tags exceeds the entry budget`);
|
||||
}
|
||||
const tags = value.tags.map((tag, tagIndex) => boundedText(tag, `${path}.tags[${tagIndex}]`, ASSET_CATALOG_V2_BUDGET.maxTagBytes, state));
|
||||
if (!Array.isArray(value.customProperties) || value.customProperties.length > ASSET_CATALOG_V2_BUDGET.maxCustomPropertiesPerAsset) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.customProperties exceeds the entry budget`);
|
||||
}
|
||||
const customProperties = value.customProperties.map((item, propertyIndex) => parseCustomProperty(item, `${path}.customProperties[${propertyIndex}]`, state));
|
||||
const activeTag = boundedInteger(value.activeTag, `${path}.activeTag`, 0, Math.max(tags.length - 1, 0));
|
||||
if (typeof value.usePreferredImportMethod !== "boolean" || !["LINK", "APPEND", "PACK"].includes(value.preferredImportMethod as string)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} has invalid import method metadata`);
|
||||
}
|
||||
return {
|
||||
assetId: value.assetId,
|
||||
...identity,
|
||||
idType,
|
||||
name: boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxAssetNameBytes, state),
|
||||
catalogId,
|
||||
catalogSimpleName: boundedText(value.catalogSimpleName, `${path}.catalogSimpleName`, ASSET_CATALOG_V2_BUDGET.maxCatalogSimpleNameBytes, state, true),
|
||||
author: boundedText(value.author, `${path}.author`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
description: boundedText(value.description, `${path}.description`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
copyright: boundedText(value.copyright, `${path}.copyright`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
license: boundedText(value.license, `${path}.license`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
tags,
|
||||
activeTag,
|
||||
usePreferredImportMethod: value.usePreferredImportMethod,
|
||||
preferredImportMethod: value.preferredImportMethod as AssetCatalogV2ImportMethod,
|
||||
customProperties,
|
||||
sourceSha256: value.sourceSha256 === null ? null : digest(value.sourceSha256, `${path}.sourceSha256`),
|
||||
sourcePath: value.sourcePath === null ? null : boundedText(value.sourcePath, `${path}.sourcePath`, ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes, state),
|
||||
preview: value.preview === null ? null : parsePreview(value.preview, `${path}.preview`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseAssetCatalogManifestV2(value: unknown): Promise<AssetCatalogManifestV2IR> {
|
||||
if (!record(value) || value.schemaVersion !== ASSET_CATALOG_SCHEMA) {
|
||||
throw new AssetCatalogV2ValidationError("PROTOCOL_MISMATCH", "unsupported asset catalog schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "revision", "catalogs", "assets", "libraries"], "manifest");
|
||||
if (!Array.isArray(value.catalogs) || value.catalogs.length > ASSET_CATALOG_V2_BUDGET.maxCatalogs ||
|
||||
!Array.isArray(value.assets) || value.assets.length > ASSET_CATALOG_V2_BUDGET.maxAssets ||
|
||||
!Array.isArray(value.libraries) || value.libraries.length > ASSET_CATALOG_V2_BUDGET.maxLibraries) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", "manifest exceeds the catalog, asset, or library entry budget");
|
||||
}
|
||||
const state = { total: 0 };
|
||||
const catalogs = value.catalogs.map((item, index) => parseCatalog(item, index, state));
|
||||
const catalogIds = new Set(catalogs.map((item) => item.catalogId));
|
||||
if (catalogIds.size !== catalogs.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate catalog IDs");
|
||||
const assets = await Promise.all(value.assets.map((item, index) => parseAsset(item, index, catalogIds, state)));
|
||||
if (new Set(assets.map((item) => item.assetId)).size !== assets.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate asset IDs");
|
||||
const libraries = value.libraries.map((item, index) => parseLibrary(item, index, state));
|
||||
assertLibraryGraph(libraries);
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
revision: boundedInteger(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
|
||||
catalogs,
|
||||
assets,
|
||||
libraries,
|
||||
};
|
||||
}
|
||||
|
||||
function desktopProperty(value: unknown, path: string): AssetCatalogV2CustomProperty {
|
||||
if (!record(value) || typeof value.name !== "string" || typeof value.type !== "string") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
|
||||
}
|
||||
if (value.type === "STR") return { name: value.name, type: "STRING", value: value.value as string };
|
||||
if (value.type === "IDPROPERTYARRAY") {
|
||||
return { name: value.name, type: "FLOAT_ARRAY", value: value.value as number[] };
|
||||
}
|
||||
if (["BOOL", "INT", "FLOAT"].includes(value.type)) {
|
||||
return { name: value.name, type: value.type as "BOOL", value: value.value as boolean } as AssetCatalogV2CustomProperty;
|
||||
}
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.type is unsupported`);
|
||||
}
|
||||
|
||||
export async function createAssetCatalogManifestV2FromDesktop(value: unknown, revision = 0): Promise<AssetCatalogManifestV2IR> {
|
||||
if (!record(value) || value.schemaVersion !== 1 || value.task !== "M12-01B" || !record(value.catalogDefinition) ||
|
||||
!Array.isArray(value.catalogDefinition.records) || !Array.isArray(value.assets)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "desktop catalog canonical report is invalid");
|
||||
}
|
||||
const catalogs = value.catalogDefinition.records.map((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop catalog ${index} is invalid`);
|
||||
return { catalogId: item.catalogId, path: item.path, simpleName: item.simpleName, parentPath: item.parentPath };
|
||||
});
|
||||
const catalogIds = new Set(catalogs.map((item) => item.catalogId));
|
||||
const assets = await Promise.all(value.assets.map(async (item, index) => {
|
||||
if (!record(item) || !ASSET_CATALOG_V2_ID_TYPES.includes(item.idType as AssetCatalogV2IDType) || !Array.isArray(item.tags) || !Array.isArray(item.customProperties)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop asset ${index} is invalid`);
|
||||
}
|
||||
const idType = item.idType as AssetCatalogV2IDType;
|
||||
const assetLibraryIdentifier = null;
|
||||
const relativeAssetIdentifier = `${ID_TYPE_DIRECTORY[idType]}/${item.name}`;
|
||||
const desktopCatalogId = item.catalogId === NIL_UUID ? null : item.catalogId;
|
||||
if (desktopCatalogId !== null && !catalogIds.has(desktopCatalogId)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop asset ${index} references an unknown catalog`);
|
||||
return {
|
||||
assetId: await createAssetCatalogV2StableId({ assetLibraryIdentifier, relativeAssetIdentifier }),
|
||||
assetLibraryIdentifier,
|
||||
relativeAssetIdentifier,
|
||||
idType,
|
||||
name: item.name,
|
||||
catalogId: desktopCatalogId,
|
||||
catalogSimpleName: item.catalogSimpleName,
|
||||
author: item.author,
|
||||
description: item.description,
|
||||
copyright: item.copyright,
|
||||
license: item.license,
|
||||
tags: item.tags,
|
||||
activeTag: item.activeTag,
|
||||
usePreferredImportMethod: item.usePreferredImportMethod,
|
||||
preferredImportMethod: item.preferredImportMethod,
|
||||
customProperties: item.customProperties.map((property, propertyIndex) => desktopProperty(property, `desktop asset ${index} property ${propertyIndex}`)),
|
||||
sourceSha256: null,
|
||||
sourcePath: null,
|
||||
preview: null,
|
||||
};
|
||||
}));
|
||||
const manifest = { schemaVersion: ASSET_CATALOG_SCHEMA, revision, catalogs, assets, libraries: [] };
|
||||
return parseAssetCatalogManifestV2(manifest);
|
||||
}
|
||||
131
web/protocol/asset-preview-decode.ts
Normal file
131
web/protocol/asset-preview-decode.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
parseAssetPreviewIdentity,
|
||||
type AssetPreviewContentMimeType,
|
||||
type AssetPreviewIdentityIR,
|
||||
} from "./asset-preview";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_PREVIEW_DECODE_SCHEMA = 1 as const;
|
||||
export const ASSET_PREVIEW_DECODE_BUDGET = Object.freeze({
|
||||
maxEncodedBytes: 16 * 1024 * 1024,
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
maxPixels: 4096 * 4096,
|
||||
maxDecodedBytes: 64 * 1024 * 1024,
|
||||
maxCompressionRatio: 100,
|
||||
});
|
||||
|
||||
export interface AssetPreviewDecodePlanIR {
|
||||
schemaVersion: typeof ASSET_PREVIEW_DECODE_SCHEMA;
|
||||
identitySha256: string;
|
||||
mimeType: AssetPreviewContentMimeType;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelCount: number;
|
||||
encodedByteLength: number;
|
||||
decodedByteLength: number;
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
export class AssetPreviewDecodeError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetPreviewDecodeError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function text(bytes: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
||||
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
if (bytes.byteLength < 24 || !signature.every((value, index) => bytes[index] === value)) return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(8, false) !== 13 || text(bytes, 12, 4) !== "IHDR") return null;
|
||||
return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
|
||||
}
|
||||
|
||||
function webPDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
||||
if (bytes.byteLength < 30 || text(bytes, 0, 4) !== "RIFF" || text(bytes, 8, 4) !== "WEBP") return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(4, true) + 8 !== bytes.byteLength) return null;
|
||||
const chunk = text(bytes, 12, 4);
|
||||
if (chunk === "VP8X") {
|
||||
const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16);
|
||||
const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16);
|
||||
return { width, height };
|
||||
}
|
||||
if (chunk === "VP8L" && bytes[20] === 0x2f) {
|
||||
const packed = view.getUint32(21, true);
|
||||
return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
|
||||
}
|
||||
if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||||
return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function encodedDimensions(mimeType: AssetPreviewContentMimeType, bytes: Uint8Array): { width: number; height: number } {
|
||||
const dimensions = mimeType === "image/png" ? pngDimensions(bytes) : webPDimensions(bytes);
|
||||
if (!dimensions) throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", `encoded bytes are not a valid ${mimeType} header`);
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
async function sha256(bytes: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function assertEncodedBudget(identity: AssetPreviewIdentityIR, encodedBytes: ArrayBuffer): void {
|
||||
if (!(encodedBytes instanceof ArrayBuffer)) {
|
||||
throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded payload must be an ArrayBuffer");
|
||||
}
|
||||
if (identity.content.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes ||
|
||||
encodedBytes.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview encoded byte length exceeds the pre-decode budget");
|
||||
}
|
||||
if (encodedBytes.byteLength !== identity.content.byteLength) {
|
||||
throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded byte length does not match the identity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function planAssetPreviewDecode(value: unknown, encodedBytes: ArrayBuffer): Promise<AssetPreviewDecodePlanIR> {
|
||||
const identity = await parseAssetPreviewIdentity(value);
|
||||
assertEncodedBudget(identity, encodedBytes);
|
||||
if (await sha256(encodedBytes) !== identity.content.sha256) {
|
||||
throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded SHA-256 does not match the identity");
|
||||
}
|
||||
|
||||
const dimensions = encodedDimensions(identity.content.mimeType, new Uint8Array(encodedBytes));
|
||||
if (dimensions.width !== identity.content.width || dimensions.height !== identity.content.height) {
|
||||
throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded dimensions do not match the identity");
|
||||
}
|
||||
if (dimensions.width > ASSET_PREVIEW_DECODE_BUDGET.maxWidth || dimensions.height > ASSET_PREVIEW_DECODE_BUDGET.maxHeight) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview dimensions exceed the pre-decode budget");
|
||||
}
|
||||
const pixelCount = dimensions.width * dimensions.height;
|
||||
const decodedByteLength = pixelCount * 4;
|
||||
if (!Number.isSafeInteger(pixelCount) || pixelCount > ASSET_PREVIEW_DECODE_BUDGET.maxPixels ||
|
||||
!Number.isSafeInteger(decodedByteLength) || decodedByteLength > ASSET_PREVIEW_DECODE_BUDGET.maxDecodedBytes) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview pixel or decoded byte count exceeds the pre-decode budget");
|
||||
}
|
||||
const compressionRatio = decodedByteLength / encodedBytes.byteLength;
|
||||
if (!Number.isFinite(compressionRatio) || compressionRatio > ASSET_PREVIEW_DECODE_BUDGET.maxCompressionRatio) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview compression ratio exceeds the pre-decode budget");
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_PREVIEW_DECODE_SCHEMA,
|
||||
identitySha256: identity.identitySha256,
|
||||
mimeType: identity.content.mimeType,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
pixelCount,
|
||||
encodedByteLength: encodedBytes.byteLength,
|
||||
decodedByteLength,
|
||||
compressionRatio,
|
||||
};
|
||||
}
|
||||
202
web/protocol/asset-preview.ts
Normal file
202
web/protocol/asset-preview.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_PREVIEW_IDENTITY_SCHEMA = 1 as const;
|
||||
export const ASSET_PREVIEW_SOURCE_MIME_TYPES = [
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"application/x-blender-preview-rgba8",
|
||||
] as const;
|
||||
export const ASSET_PREVIEW_CONTENT_MIME_TYPES = ["image/png", "image/webp"] as const;
|
||||
|
||||
export type AssetPreviewSourceMimeType = (typeof ASSET_PREVIEW_SOURCE_MIME_TYPES)[number];
|
||||
export type AssetPreviewContentMimeType = (typeof ASSET_PREVIEW_CONTENT_MIME_TYPES)[number];
|
||||
|
||||
export interface AssetPreviewSourceIdentityIR {
|
||||
mimeType: AssetPreviewSourceMimeType;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface AssetPreviewContentIdentityIR {
|
||||
mimeType: AssetPreviewContentMimeType;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelFormat: "RGBA8";
|
||||
colorSpace: "SRGB";
|
||||
alphaMode: "STRAIGHT" | "PREMULTIPLIED";
|
||||
}
|
||||
|
||||
export interface AssetPreviewGeneratorIdentityIR {
|
||||
name: string;
|
||||
version: string;
|
||||
executableSha256: string;
|
||||
scriptSha256: string;
|
||||
settingsSha256: string;
|
||||
}
|
||||
|
||||
export interface AssetPreviewIdentityIR {
|
||||
schemaVersion: typeof ASSET_PREVIEW_IDENTITY_SCHEMA;
|
||||
assetId: string;
|
||||
slot: "PREVIEW";
|
||||
source: AssetPreviewSourceIdentityIR;
|
||||
content: AssetPreviewContentIdentityIR;
|
||||
generator: AssetPreviewGeneratorIdentityIR;
|
||||
identitySha256: string;
|
||||
}
|
||||
|
||||
export class AssetPreviewIdentityError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetPreviewIdentityError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const keys = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !keys.has(key))) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} contains unknown fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximum = 256): string {
|
||||
if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximum) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a positive safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSource(value: unknown): AssetPreviewSourceIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source is invalid");
|
||||
exactKeys(value, ["mimeType", "byteLength", "sha256"], "source");
|
||||
if (!ASSET_PREVIEW_SOURCE_MIME_TYPES.includes(value.mimeType as AssetPreviewSourceMimeType)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source.mimeType is unsupported");
|
||||
}
|
||||
return {
|
||||
mimeType: value.mimeType as AssetPreviewSourceMimeType,
|
||||
byteLength: positiveInteger(value.byteLength, "source.byteLength"),
|
||||
sha256: digest(value.sha256, "source.sha256"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseContent(value: unknown): AssetPreviewContentIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content is invalid");
|
||||
exactKeys(value, ["mimeType", "byteLength", "sha256", "width", "height", "pixelFormat", "colorSpace", "alphaMode"], "content");
|
||||
if (!ASSET_PREVIEW_CONTENT_MIME_TYPES.includes(value.mimeType as AssetPreviewContentMimeType) ||
|
||||
value.pixelFormat !== "RGBA8" || value.colorSpace !== "SRGB" ||
|
||||
!["STRAIGHT", "PREMULTIPLIED"].includes(value.alphaMode as string)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content encoding profile is unsupported");
|
||||
}
|
||||
return {
|
||||
mimeType: value.mimeType as AssetPreviewContentMimeType,
|
||||
byteLength: positiveInteger(value.byteLength, "content.byteLength"),
|
||||
sha256: digest(value.sha256, "content.sha256"),
|
||||
width: positiveInteger(value.width, "content.width"),
|
||||
height: positiveInteger(value.height, "content.height"),
|
||||
pixelFormat: "RGBA8",
|
||||
colorSpace: "SRGB",
|
||||
alphaMode: value.alphaMode as AssetPreviewContentIdentityIR["alphaMode"],
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenerator(value: unknown): AssetPreviewGeneratorIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "generator is invalid");
|
||||
exactKeys(value, ["name", "version", "executableSha256", "scriptSha256", "settingsSha256"], "generator");
|
||||
return {
|
||||
name: text(value.name, "generator.name", 128),
|
||||
version: text(value.version, "generator.version", 64),
|
||||
executableSha256: digest(value.executableSha256, "generator.executableSha256"),
|
||||
scriptSha256: digest(value.scriptSha256, "generator.scriptSha256"),
|
||||
settingsSha256: digest(value.settingsSha256, "generator.settingsSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
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(bytes: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function computeAssetPreviewIdentity(value: Omit<AssetPreviewIdentityIR, "identitySha256">): Promise<string> {
|
||||
return sha256Bytes(encoder.encode(stableJSON(value)).buffer);
|
||||
}
|
||||
|
||||
function parseBase(value: unknown): Omit<AssetPreviewIdentityIR, "identitySha256"> {
|
||||
if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_IDENTITY_SCHEMA || value.slot !== "PREVIEW") {
|
||||
throw new AssetPreviewIdentityError("PROTOCOL_MISMATCH", "unsupported asset preview identity schema or slot");
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_PREVIEW_IDENTITY_SCHEMA,
|
||||
assetId: text(value.assetId, "assetId", 256),
|
||||
slot: "PREVIEW",
|
||||
source: parseSource(value.source),
|
||||
content: parseContent(value.content),
|
||||
generator: parseGenerator(value.generator),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAssetPreviewIdentity(value: Omit<AssetPreviewIdentityIR, "identitySha256">): Promise<AssetPreviewIdentityIR> {
|
||||
const parsed = parseBase(value);
|
||||
return { ...parsed, identitySha256: await computeAssetPreviewIdentity(parsed) };
|
||||
}
|
||||
|
||||
export async function parseAssetPreviewIdentity(value: unknown): Promise<AssetPreviewIdentityIR> {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "preview identity is invalid");
|
||||
exactKeys(value, ["schemaVersion", "assetId", "slot", "source", "content", "generator", "identitySha256"], "preview");
|
||||
const parsed = parseBase(value);
|
||||
const identitySha256 = digest(value.identitySha256, "identitySha256");
|
||||
if (await computeAssetPreviewIdentity(parsed) !== identitySha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview identity hash does not match its fields");
|
||||
}
|
||||
return { ...parsed, identitySha256 };
|
||||
}
|
||||
|
||||
export async function verifyAssetPreviewIdentity(
|
||||
value: unknown,
|
||||
sourceBytes: ArrayBuffer,
|
||||
contentBytes: ArrayBuffer,
|
||||
generator: AssetPreviewGeneratorIdentityIR,
|
||||
): Promise<AssetPreviewIdentityIR> {
|
||||
const identity = await parseAssetPreviewIdentity(value);
|
||||
const parsedGenerator = parseGenerator(generator);
|
||||
if (stableJSON(parsedGenerator) !== stableJSON(identity.generator)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview generator identity changed");
|
||||
}
|
||||
if (!(sourceBytes instanceof ArrayBuffer) || sourceBytes.byteLength !== identity.source.byteLength || await sha256Bytes(sourceBytes) !== identity.source.sha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview source bytes do not match the source identity");
|
||||
}
|
||||
if (!(contentBytes instanceof ArrayBuffer) || contentBytes.byteLength !== identity.content.byteLength || await sha256Bytes(contentBytes) !== identity.content.sha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview content bytes do not match the content identity");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -160,6 +160,13 @@ export class CompositorFrameCache {
|
||||
this.entries.set(key, { result: clone, byteLength });
|
||||
this.currentBytes += byteLength;
|
||||
}
|
||||
|
||||
clear(): number {
|
||||
const releasedBytes = this.currentBytes;
|
||||
this.entries.clear();
|
||||
this.currentBytes = 0;
|
||||
return releasedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
export class CompositorValidationError extends Error {
|
||||
|
||||
@@ -160,6 +160,8 @@ export type ErrorCode =
|
||||
| "ASSET_BUDGET_EXCEEDED"
|
||||
| "ASSET_SOURCE_HASH_MISMATCH"
|
||||
| "ASSET_LICENSE_MISSING"
|
||||
| "ASSET_SCHEMA_DOWNGRADE_BLOCKED"
|
||||
| "ASSET_PREVIEW_IDENTITY_MISMATCH"
|
||||
| "LIBRARY_DEPENDENCY_CYCLE"
|
||||
| "LIBRARY_MUTATION_UNAVAILABLE"
|
||||
| "IO_FORMAT_UNSUPPORTED"
|
||||
|
||||
173
web/protocol/library-main-append.ts
Normal file
173
web/protocol/library-main-append.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import {
|
||||
parseLibraryOperationBinding,
|
||||
type LibraryOperationBindingIR,
|
||||
} from "./library-operation-identity";
|
||||
|
||||
export const LIBRARY_MAIN_APPEND_SCHEMA = 1 as const;
|
||||
export const LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface LibraryAppendClosureIR {
|
||||
object: string;
|
||||
mesh: string;
|
||||
material: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendRequestIR {
|
||||
schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA;
|
||||
baseRevision: number;
|
||||
binding: LibraryOperationBindingIR;
|
||||
expectedClosure: LibraryAppendClosureIR;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendMappingIR {
|
||||
source: string;
|
||||
local: string;
|
||||
owner: "LOCAL_MAIN";
|
||||
readOnly: false;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendReceiptIR {
|
||||
schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA;
|
||||
operation: "APPEND";
|
||||
sourceLibraryId: string;
|
||||
sourceDataBlockId: string;
|
||||
dependencyClosureSha256: string;
|
||||
baseRevision: number;
|
||||
nextRevision: number;
|
||||
transactionCount: 1;
|
||||
mapping: LibraryMainAppendMappingIR[];
|
||||
}
|
||||
|
||||
export class LibraryMainAppendError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryMainAppendError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const PREFIXES = Object.freeze({
|
||||
object: "Object/",
|
||||
mesh: "Mesh/",
|
||||
material: "Material/",
|
||||
image: "Image/",
|
||||
});
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], path: string): void {
|
||||
const allowed = new Set(keys);
|
||||
if (Object.keys(value).length !== keys.length || Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `${path} fields are not exact`);
|
||||
}
|
||||
}
|
||||
|
||||
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 sha256Text(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function sha256LibraryBytes(value: ArrayBuffer): Promise<string> {
|
||||
if (!(value instanceof ArrayBuffer) || value.byteLength === 0 || value.byteLength > LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES) {
|
||||
throw new LibraryMainAppendError("ASSET_BUDGET_EXCEEDED", "library source must contain 1..64 MiB");
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function parseLibraryAppendClosure(value: unknown): LibraryAppendClosureIR {
|
||||
if (!record(value)) throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "expectedClosure must be an object");
|
||||
exactKeys(value, Object.keys(PREFIXES), "expectedClosure");
|
||||
const result = {} as Record<keyof LibraryAppendClosureIR, string>;
|
||||
for (const key of Object.keys(PREFIXES) as Array<keyof LibraryAppendClosureIR>) {
|
||||
const prefix = PREFIXES[key];
|
||||
const item = value[key];
|
||||
if (typeof item !== "string" || !item.startsWith(prefix) || item.length === prefix.length ||
|
||||
encoder.encode(item.slice(prefix.length)).byteLength > 63) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `expectedClosure.${key} is invalid`);
|
||||
}
|
||||
result[key] = item;
|
||||
}
|
||||
if (new Set(Object.values(result)).size !== 4) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "append closure IDs must be unique");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function computeLibraryAppendClosureSha256(value: unknown): Promise<string> {
|
||||
const closure = parseLibraryAppendClosure(value);
|
||||
return sha256Text(stableJSON({ schema: "BLENDER_APPEND_OBJECT_CLOSURE_V1", ...closure }));
|
||||
}
|
||||
|
||||
export async function parseLibraryMainAppendRequest(
|
||||
value: unknown,
|
||||
currentRevision?: number,
|
||||
): Promise<LibraryMainAppendRequestIR> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_MAIN_APPEND_SCHEMA) {
|
||||
throw new LibraryMainAppendError("PROTOCOL_MISMATCH", "unsupported library Main append schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "baseRevision", "binding", "expectedClosure"], "request");
|
||||
if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "baseRevision must be a non-negative safe integer");
|
||||
}
|
||||
if (currentRevision !== undefined && value.baseRevision !== currentRevision) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "library append base revision is stale");
|
||||
}
|
||||
const binding = await parseLibraryOperationBinding(value.binding);
|
||||
const expectedClosure = parseLibraryAppendClosure(value.expectedClosure);
|
||||
if (binding.operation !== "APPEND" || binding.owner.kind !== "LOCAL_MAIN" || binding.readOnly || binding.referenceReadOnly) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "library Main append requires a writable LOCAL_MAIN binding");
|
||||
}
|
||||
if (binding.sourceDataBlockId !== expectedClosure.object || binding.owner.localDataBlockId !== expectedClosure.object) {
|
||||
throw new LibraryMainAppendError("ASSET_SOURCE_HASH_MISMATCH", "append root and local owner do not match the expected Object");
|
||||
}
|
||||
if (binding.dependencyClosureSha256 !== await computeLibraryAppendClosureSha256(expectedClosure)) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "append dependency closure binding is stale");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA,
|
||||
baseRevision: value.baseRevision,
|
||||
binding,
|
||||
expectedClosure,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLibraryMainAppendReceipt(
|
||||
request: LibraryMainAppendRequestIR,
|
||||
nextRevision: number,
|
||||
): LibraryMainAppendReceiptIR {
|
||||
if (!Number.isSafeInteger(nextRevision) || nextRevision !== request.baseRevision + 1) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "library append must advance exactly one Main revision");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA,
|
||||
operation: "APPEND",
|
||||
sourceLibraryId: request.binding.source.sourceLibraryId,
|
||||
sourceDataBlockId: request.binding.sourceDataBlockId,
|
||||
dependencyClosureSha256: request.binding.dependencyClosureSha256,
|
||||
baseRevision: request.baseRevision,
|
||||
nextRevision,
|
||||
transactionCount: 1,
|
||||
mapping: Object.values(request.expectedClosure).map((source) => ({
|
||||
source,
|
||||
local: source,
|
||||
owner: "LOCAL_MAIN",
|
||||
readOnly: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
292
web/protocol/library-operation-identity.ts
Normal file
292
web/protocol/library-operation-identity.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_OPERATION_IDENTITY_SCHEMA = 1 as const;
|
||||
export const LIBRARY_OPERATION_IDENTITY_BUDGET = Object.freeze({
|
||||
maxSourceLocatorBytes: 4_096,
|
||||
maxDataBlockIdBytes: 1_024,
|
||||
maxOwnerIdBytes: 1_024,
|
||||
});
|
||||
|
||||
export type LibraryOperation = "APPEND" | "LINK" | "LIBRARY_OVERRIDE";
|
||||
|
||||
export interface LibrarySourceIdentityIR {
|
||||
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
|
||||
sourceLibraryId: string;
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
}
|
||||
|
||||
export interface LocalMainOwnerIR {
|
||||
kind: "LOCAL_MAIN";
|
||||
projectId: string;
|
||||
localDataBlockId: string;
|
||||
}
|
||||
|
||||
export interface SourceLibraryOwnerIR {
|
||||
kind: "SOURCE_LIBRARY";
|
||||
sourceLibraryId: string;
|
||||
sourceDataBlockId: string;
|
||||
}
|
||||
|
||||
export interface LocalOverrideOwnerIR {
|
||||
kind: "LOCAL_OVERRIDE";
|
||||
projectId: string;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
}
|
||||
|
||||
export type LibraryOperationOwnerIR = LocalMainOwnerIR | SourceLibraryOwnerIR | LocalOverrideOwnerIR;
|
||||
|
||||
export interface LibraryOperationBindingIR {
|
||||
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
|
||||
operation: LibraryOperation;
|
||||
source: LibrarySourceIdentityIR;
|
||||
sourceDataBlockId: string;
|
||||
owner: LibraryOperationOwnerIR;
|
||||
readOnly: boolean;
|
||||
referenceReadOnly: boolean;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
invalidationToken: string;
|
||||
}
|
||||
|
||||
export interface LibrarySourceStateIR {
|
||||
sourceLibraryId: string;
|
||||
sourceSha256: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
}
|
||||
|
||||
export class LibraryOperationIdentityError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryOperationIdentityError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const INVALIDATION_TOKEN = /^libtoken:[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const keys = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !keys.has(key))) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximumBytes: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximumBytes) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} is outside its UTF-8 byte budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, minimum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a safe integer >= ${minimum}`);
|
||||
}
|
||||
return 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: string): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function computeLibrarySourceId(value: { sourceLocator: string; sourceSha256: string }): Promise<string> {
|
||||
return `library:${await sha256(stableJSON({
|
||||
schema: "BLENDER_LIBRARY_SOURCE_V1",
|
||||
sourceLocator: value.sourceLocator,
|
||||
sourceSha256: value.sourceSha256,
|
||||
}))}`;
|
||||
}
|
||||
|
||||
export async function createLibrarySourceIdentity(value: {
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
}): Promise<LibrarySourceIdentityIR> {
|
||||
const sourceLocator = text(value.sourceLocator, "sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
|
||||
const sourceSha256 = digest(value.sourceSha256, "sourceSha256");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
sourceLibraryId: await computeLibrarySourceId({ sourceLocator, sourceSha256 }),
|
||||
sourceLocator,
|
||||
sourceSha256,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseLibrarySourceIdentity(value: unknown): Promise<LibrarySourceIdentityIR> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
|
||||
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library source identity schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "sourceLibraryId", "sourceLocator", "sourceSha256"], "source");
|
||||
const sourceLocator = text(value.sourceLocator, "source.sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
|
||||
const sourceSha256 = digest(value.sourceSha256, "source.sourceSha256");
|
||||
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId) ||
|
||||
value.sourceLibraryId !== await computeLibrarySourceId({ sourceLocator, sourceSha256 })) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "sourceLibraryId does not bind locator and source SHA-256");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
sourceLibraryId: value.sourceLibraryId,
|
||||
sourceLocator,
|
||||
sourceSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOwner(value: unknown, operation: LibraryOperation, source: LibrarySourceIdentityIR, sourceDataBlockId: string): LibraryOperationOwnerIR {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "owner must be an object");
|
||||
if (operation === "APPEND") {
|
||||
exactKeys(value, ["kind", "projectId", "localDataBlockId"], "owner");
|
||||
if (value.kind !== "LOCAL_MAIN") throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "APPEND owner must be LOCAL_MAIN");
|
||||
return {
|
||||
kind: "LOCAL_MAIN",
|
||||
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
|
||||
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
};
|
||||
}
|
||||
if (operation === "LINK") {
|
||||
exactKeys(value, ["kind", "sourceLibraryId", "sourceDataBlockId"], "owner");
|
||||
if (value.kind !== "SOURCE_LIBRARY" || value.sourceLibraryId !== source.sourceLibraryId || value.sourceDataBlockId !== sourceDataBlockId) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LINK owner must match the source library and data-block");
|
||||
}
|
||||
return { kind: "SOURCE_LIBRARY", sourceLibraryId: source.sourceLibraryId, sourceDataBlockId };
|
||||
}
|
||||
exactKeys(value, ["kind", "projectId", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId"], "owner");
|
||||
if (value.kind !== "LOCAL_OVERRIDE" || value.referenceSourceDataBlockId !== sourceDataBlockId) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LIBRARY_OVERRIDE owner must retain the source reference");
|
||||
}
|
||||
return {
|
||||
kind: "LOCAL_OVERRIDE",
|
||||
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
|
||||
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
referenceSourceDataBlockId: sourceDataBlockId,
|
||||
hierarchyRootDataBlockId: text(value.hierarchyRootDataBlockId, "owner.hierarchyRootDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
};
|
||||
}
|
||||
|
||||
type LibraryOperationBindingInput = Omit<LibraryOperationBindingIR, "invalidationToken">;
|
||||
|
||||
function assertOwnershipSemantics(operation: LibraryOperation, readOnly: boolean, referenceReadOnly: boolean): void {
|
||||
const valid = operation === "APPEND"
|
||||
? !readOnly && !referenceReadOnly
|
||||
: operation === "LINK"
|
||||
? readOnly && referenceReadOnly
|
||||
: !readOnly && referenceReadOnly;
|
||||
if (!valid) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${operation} read-only semantics are invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseBindingBase(value: unknown): Promise<LibraryOperationBindingInput> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
|
||||
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library operation binding schema");
|
||||
}
|
||||
const operation = value.operation;
|
||||
if (operation !== "APPEND" && operation !== "LINK" && operation !== "LIBRARY_OVERRIDE") {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "operation is invalid");
|
||||
}
|
||||
const source = await parseLibrarySourceIdentity(value.source);
|
||||
const sourceDataBlockId = text(value.sourceDataBlockId, "sourceDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes);
|
||||
if (typeof value.readOnly !== "boolean" || typeof value.referenceReadOnly !== "boolean") {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "read-only fields must be boolean");
|
||||
}
|
||||
assertOwnershipSemantics(operation, value.readOnly, value.referenceReadOnly);
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
operation,
|
||||
source,
|
||||
sourceDataBlockId,
|
||||
owner: parseOwner(value.owner, operation, source, sourceDataBlockId),
|
||||
readOnly: value.readOnly,
|
||||
referenceReadOnly: value.referenceReadOnly,
|
||||
sourceGeneration: integer(value.sourceGeneration, "sourceGeneration", 1),
|
||||
sourceRevision: integer(value.sourceRevision, "sourceRevision", 0),
|
||||
dependencyClosureSha256: digest(value.dependencyClosureSha256, "dependencyClosureSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeLibraryInvalidationToken(value: LibraryOperationBindingInput): Promise<string> {
|
||||
return `libtoken:${await sha256(stableJSON(value))}`;
|
||||
}
|
||||
|
||||
export async function createLibraryOperationBinding(value: LibraryOperationBindingInput): Promise<LibraryOperationBindingIR> {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
|
||||
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
|
||||
], "binding");
|
||||
const parsed = await parseBindingBase(value);
|
||||
return { ...parsed, invalidationToken: await computeLibraryInvalidationToken(parsed) };
|
||||
}
|
||||
|
||||
export async function parseLibraryOperationBinding(value: unknown): Promise<LibraryOperationBindingIR> {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
|
||||
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
|
||||
"invalidationToken",
|
||||
], "binding");
|
||||
const parsed = await parseBindingBase(value);
|
||||
if (typeof value.invalidationToken !== "string" || !INVALIDATION_TOKEN.test(value.invalidationToken) ||
|
||||
value.invalidationToken !== await computeLibraryInvalidationToken(parsed)) {
|
||||
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library operation invalidation token is stale or forged");
|
||||
}
|
||||
return { ...parsed, invalidationToken: value.invalidationToken };
|
||||
}
|
||||
|
||||
function parseSourceState(value: unknown): LibrarySourceStateIR {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state must be an object");
|
||||
exactKeys(value, ["sourceLibraryId", "sourceSha256", "sourceGeneration", "sourceRevision", "dependencyClosureSha256"], "source state");
|
||||
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId)) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state library ID is invalid");
|
||||
}
|
||||
return {
|
||||
sourceLibraryId: value.sourceLibraryId,
|
||||
sourceSha256: digest(value.sourceSha256, "source state sourceSha256"),
|
||||
sourceGeneration: integer(value.sourceGeneration, "source state sourceGeneration", 1),
|
||||
sourceRevision: integer(value.sourceRevision, "source state sourceRevision", 0),
|
||||
dependencyClosureSha256: digest(value.dependencyClosureSha256, "source state dependencyClosureSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertLibraryOperationBindingCurrent(
|
||||
bindingValue: unknown,
|
||||
sourceStateValue: unknown,
|
||||
): Promise<LibraryOperationBindingIR> {
|
||||
const binding = await parseLibraryOperationBinding(bindingValue);
|
||||
const state = parseSourceState(sourceStateValue);
|
||||
if (binding.source.sourceLibraryId !== state.sourceLibraryId || binding.source.sourceSha256 !== state.sourceSha256 ||
|
||||
binding.sourceGeneration !== state.sourceGeneration || binding.sourceRevision !== state.sourceRevision ||
|
||||
binding.dependencyClosureSha256 !== state.dependencyClosureSha256) {
|
||||
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library source or dependency closure has been invalidated");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
197
web/protocol/render-compositor-media-recovery.ts
Normal file
197
web/protocol/render-compositor-media-recovery.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
export const RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION = 1 as const;
|
||||
export const RENDER_COMPOSITOR_MEDIA_DOMAINS = ["RENDER", "COMPOSITOR", "MEDIA"] as const;
|
||||
export type RenderCompositorMediaDomain = typeof RENDER_COMPOSITOR_MEDIA_DOMAINS[number];
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CANCELLATION_CODES = {
|
||||
RENDER: "OPEN_CANCELLED",
|
||||
COMPOSITOR: "COMPOSITOR_CANCELLED",
|
||||
MEDIA: "SEQUENCER_CANCELLED",
|
||||
} as const;
|
||||
const BUDGET_CODES = {
|
||||
RENDER: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
COMPOSITOR: "COMPOSITOR_BUDGET_EXCEEDED",
|
||||
MEDIA: "SEQUENCER_BUDGET_EXCEEDED",
|
||||
} as const;
|
||||
|
||||
export interface RenderCompositorMediaRecoveryEvidenceIR {
|
||||
schemaVersion: typeof RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION;
|
||||
domain: RenderCompositorMediaDomain;
|
||||
source: {
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
};
|
||||
cancellation: {
|
||||
status: "CANCELLED";
|
||||
code: typeof CANCELLATION_CODES[RenderCompositorMediaDomain];
|
||||
publishedResults: 0;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
restart: {
|
||||
status: "RECOVERED";
|
||||
generationBefore: 1;
|
||||
generationAfter: 2;
|
||||
identityBefore: string;
|
||||
identityAfter: string;
|
||||
outputSha256Before: string;
|
||||
outputSha256After: string;
|
||||
};
|
||||
budget: {
|
||||
status: "BLOCKED";
|
||||
code: typeof BUDGET_CODES[RenderCompositorMediaDomain];
|
||||
retainedIdentityHash: string;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
release: {
|
||||
status: "RELEASED";
|
||||
releasedBytes: number;
|
||||
releasedResources: number;
|
||||
resourcesAfter: 0;
|
||||
};
|
||||
recovery: {
|
||||
status: "RECOVERED";
|
||||
identityHash: string;
|
||||
outputSha256: string;
|
||||
outputBytes: number;
|
||||
visibleUnits: number;
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
||||
const allowed = new Set(fields);
|
||||
const extra = Object.keys(value).find((field) => !allowed.has(field));
|
||||
if (extra) throw new Error(`M11_RECOVERY_INVALID: ${label}.${extra} is undeclared`);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum = 0): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a safe integer >= ${minimum}`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function zero(value: unknown, label: string): 0 {
|
||||
if (value !== 0) throw new Error(`M11_RECOVERY_INVALID: ${label} must be zero`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseRenderCompositorMediaRecoveryEvidence(value: unknown): RenderCompositorMediaRecoveryEvidenceIR {
|
||||
const input = record(value, "evidence");
|
||||
exact(input, ["schemaVersion", "domain", "source", "cancellation", "restart", "budget", "release", "recovery"], "evidence");
|
||||
if (input.schemaVersion !== RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION) {
|
||||
throw new Error("M11_RECOVERY_INVALID: unsupported schema version");
|
||||
}
|
||||
if (!RENDER_COMPOSITOR_MEDIA_DOMAINS.includes(input.domain as RenderCompositorMediaDomain)) {
|
||||
throw new Error("M11_RECOVERY_INVALID: domain is invalid");
|
||||
}
|
||||
const domain = input.domain as RenderCompositorMediaDomain;
|
||||
|
||||
const source = record(input.source, "source");
|
||||
exact(source, ["byteLength", "sha256"], "source");
|
||||
const sourceByteLength = integer(source.byteLength, "source.byteLength", 1);
|
||||
const sourceSha256 = digest(source.sha256, "source.sha256");
|
||||
|
||||
const cancellation = record(input.cancellation, "cancellation");
|
||||
exact(cancellation, ["status", "code", "publishedResults", "temporaryResourcesAfter"], "cancellation");
|
||||
if (cancellation.status !== "CANCELLED" || cancellation.code !== CANCELLATION_CODES[domain]) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${domain} cancellation contract is invalid`);
|
||||
}
|
||||
zero(cancellation.publishedResults, "cancellation.publishedResults");
|
||||
zero(cancellation.temporaryResourcesAfter, "cancellation.temporaryResourcesAfter");
|
||||
|
||||
const restart = record(input.restart, "restart");
|
||||
exact(restart, ["status", "generationBefore", "generationAfter", "identityBefore", "identityAfter", "outputSha256Before", "outputSha256After"], "restart");
|
||||
if (restart.status !== "RECOVERED" || restart.generationBefore !== 1 || restart.generationAfter !== 2) {
|
||||
throw new Error("M11_RECOVERY_INVALID: restart must advance exactly from generation 1 to 2");
|
||||
}
|
||||
const identityBefore = digest(restart.identityBefore, "restart.identityBefore");
|
||||
const identityAfter = digest(restart.identityAfter, "restart.identityAfter");
|
||||
const outputSha256Before = digest(restart.outputSha256Before, "restart.outputSha256Before");
|
||||
const outputSha256After = digest(restart.outputSha256After, "restart.outputSha256After");
|
||||
if (identityBefore !== identityAfter || outputSha256Before !== outputSha256After) {
|
||||
throw new Error("M11_RECOVERY_INVALID: restart changed committed identity or output");
|
||||
}
|
||||
|
||||
const budget = record(input.budget, "budget");
|
||||
exact(budget, ["status", "code", "retainedIdentityHash", "temporaryResourcesAfter"], "budget");
|
||||
if (budget.status !== "BLOCKED" || budget.code !== BUDGET_CODES[domain]) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${domain} budget contract is invalid`);
|
||||
}
|
||||
const retainedIdentityHash = digest(budget.retainedIdentityHash, "budget.retainedIdentityHash");
|
||||
if (retainedIdentityHash !== identityAfter) {
|
||||
throw new Error("M11_RECOVERY_INVALID: budget failure changed committed identity");
|
||||
}
|
||||
zero(budget.temporaryResourcesAfter, "budget.temporaryResourcesAfter");
|
||||
|
||||
const release = record(input.release, "release");
|
||||
exact(release, ["status", "releasedBytes", "releasedResources", "resourcesAfter"], "release");
|
||||
if (release.status !== "RELEASED") throw new Error("M11_RECOVERY_INVALID: release.status");
|
||||
const releasedBytes = integer(release.releasedBytes, "release.releasedBytes", 1);
|
||||
const releasedResources = integer(release.releasedResources, "release.releasedResources", 1);
|
||||
zero(release.resourcesAfter, "release.resourcesAfter");
|
||||
|
||||
const recovery = record(input.recovery, "recovery");
|
||||
exact(recovery, ["status", "identityHash", "outputSha256", "outputBytes", "visibleUnits"], "recovery");
|
||||
if (recovery.status !== "RECOVERED") throw new Error("M11_RECOVERY_INVALID: recovery.status");
|
||||
const recoveryIdentity = digest(recovery.identityHash, "recovery.identityHash");
|
||||
const recoveryOutput = digest(recovery.outputSha256, "recovery.outputSha256");
|
||||
if (recoveryIdentity !== identityAfter || recoveryOutput !== outputSha256After) {
|
||||
throw new Error("M11_RECOVERY_INVALID: recovered identity or output does not match generation 2");
|
||||
}
|
||||
const outputBytes = integer(recovery.outputBytes, "recovery.outputBytes", 1);
|
||||
const visibleUnits = integer(recovery.visibleUnits, "recovery.visibleUnits", 1);
|
||||
|
||||
return {
|
||||
schemaVersion: RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION,
|
||||
domain,
|
||||
source: { byteLength: sourceByteLength, sha256: sourceSha256 },
|
||||
cancellation: {
|
||||
status: "CANCELLED",
|
||||
code: CANCELLATION_CODES[domain],
|
||||
publishedResults: 0,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before,
|
||||
outputSha256After,
|
||||
},
|
||||
budget: {
|
||||
status: "BLOCKED",
|
||||
code: BUDGET_CODES[domain],
|
||||
retainedIdentityHash,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: { status: "RECOVERED", identityHash: recoveryIdentity, outputSha256: recoveryOutput, outputBytes, visibleUnits },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRenderCompositorMediaRecoverySuite(value: unknown): RenderCompositorMediaRecoveryEvidenceIR[] {
|
||||
if (!Array.isArray(value) || value.length !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
||||
throw new Error("M11_RECOVERY_INVALID: suite must contain exactly three domains");
|
||||
}
|
||||
const reports = value.map(parseRenderCompositorMediaRecoveryEvidence);
|
||||
if (new Set(reports.map((report) => report.domain)).size !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
||||
throw new Error("M11_RECOVERY_INVALID: suite contains duplicate domains");
|
||||
}
|
||||
return RENDER_COMPOSITOR_MEDIA_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PaintStrokeSessionReceiptIR,
|
||||
} from "./paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append";
|
||||
|
||||
export interface MeshGeometryBuffer {
|
||||
schemaVersion: 1;
|
||||
@@ -154,6 +155,7 @@ export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "openResourceStatus" } }
|
||||
| { requestId: string; command: { type: "snapshot" } }
|
||||
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
|
||||
| { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } }
|
||||
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
|
||||
| { requestId: string; command: { type: "appendPaintStrokeChunk"; chunk: PaintStrokeSessionChunkIR } }
|
||||
| { requestId: string; command: { type: "commitPaintStroke"; session: PaintStrokeSessionCommitIR } }
|
||||
@@ -232,6 +234,7 @@ export interface WebEngineResult {
|
||||
blend?: ArrayBuffer;
|
||||
openResources?: WebEngineOpenResourceStatus;
|
||||
paintStrokeSession?: PaintStrokeSessionReceiptIR;
|
||||
libraryAppend?: LibraryMainAppendReceiptIR;
|
||||
}
|
||||
|
||||
export type WebEngineResponse =
|
||||
|
||||
114
web/tests/e2e/asset-catalog-indexeddb-migration.spec.ts
Normal file
114
web/tests/e2e/asset-catalog-indexeddb-migration.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v1.json"),
|
||||
"utf8",
|
||||
)) as unknown;
|
||||
|
||||
test("M12-01G aborts catalog migration without changing the old IndexedDB index", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (source) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
const open = (name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = (transaction: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
const seed = async (database: IDBDatabase): Promise<void> => {
|
||||
const transaction = database.transaction(["setting", "migration"], "readwrite");
|
||||
transaction.objectStore("setting").put({ id: storage.ASSET_CATALOG_INDEX_V1_ID, value: source });
|
||||
transaction.objectStore("migration").put({ id: "schema-legacy", version: 1, marker: "unchanged" });
|
||||
await complete(transaction);
|
||||
};
|
||||
const get = (store: IDBObjectStore, id: string): Promise<unknown> => new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const snapshot = async (database: IDBDatabase) => {
|
||||
const transaction = database.transaction(["setting", "migration"], "readonly");
|
||||
const settings = transaction.objectStore("setting");
|
||||
const migrations = transaction.objectStore("migration");
|
||||
const values = await Promise.all([
|
||||
get(settings, storage.ASSET_CATALOG_INDEX_V1_ID),
|
||||
get(settings, storage.ASSET_CATALOG_INDEX_V2_ID),
|
||||
get(migrations, storage.ASSET_CATALOG_MIGRATION_ID),
|
||||
get(migrations, "schema-legacy"),
|
||||
]);
|
||||
await complete(transaction);
|
||||
return { version: database.version, source: values[0], target: values[1], receipt: values[2], legacy: values[3] };
|
||||
};
|
||||
const faultCode = async (database: IDBDatabase, faultAt: "AFTER_TARGET_PUT" | "AFTER_SOURCE_DELETE"): Promise<string> => {
|
||||
try {
|
||||
await storage.migrateAssetCatalogIndexedDB(database, { faultAt });
|
||||
return "";
|
||||
}
|
||||
catch (error) {
|
||||
return String((error as Error & { code?: string }).code ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const databaseName = `m12-01g-${crypto.randomUUID()}`;
|
||||
let database = await open(databaseName);
|
||||
await seed(database);
|
||||
const baseline = await snapshot(database);
|
||||
const afterTargetCode = await faultCode(database, "AFTER_TARGET_PUT");
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const afterTargetFault = await snapshot(database);
|
||||
const afterDeleteCode = await faultCode(database, "AFTER_SOURCE_DELETE");
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const afterDeleteFault = await snapshot(database);
|
||||
const migrated = await storage.migrateAssetCatalogIndexedDB(database);
|
||||
const committed = await snapshot(database);
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
return {
|
||||
baseline,
|
||||
afterTargetCode,
|
||||
afterTargetFault,
|
||||
afterDeleteCode,
|
||||
afterDeleteFault,
|
||||
migrated: {
|
||||
status: migrated.status,
|
||||
revision: migrated.manifest.revision,
|
||||
sourceHash: migrated.receipt.sourceManifestSha256,
|
||||
targetHash: migrated.receipt.targetManifestSha256,
|
||||
},
|
||||
committed,
|
||||
};
|
||||
}, fixture);
|
||||
|
||||
expect(result.afterTargetCode).toBe("STORAGE_TRANSACTION");
|
||||
expect(result.afterDeleteCode).toBe("STORAGE_TRANSACTION");
|
||||
expect(result.afterTargetFault).toEqual(result.baseline);
|
||||
expect(result.afterDeleteFault).toEqual(result.baseline);
|
||||
expect(result.baseline.version).toBe(1);
|
||||
expect(result.baseline.source).toEqual({ id: "asset-catalog:index:v1", value: fixture });
|
||||
expect(result.baseline.target).toBeUndefined();
|
||||
expect(result.baseline.receipt).toBeUndefined();
|
||||
expect(result.baseline.legacy).toEqual({ id: "schema-legacy", version: 1, marker: "unchanged" });
|
||||
expect(result.migrated).toEqual({
|
||||
status: "MIGRATED",
|
||||
revision: 7,
|
||||
sourceHash: "cbdc8b03dc9ce1f38b44bae1c7459eb273126cbd6c7dd33115d2b1966e975881",
|
||||
targetHash: "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
|
||||
});
|
||||
expect(result.committed.version).toBe(1);
|
||||
expect(result.committed.source).toBeUndefined();
|
||||
expect(result.committed.target).toMatchObject({ id: "asset-catalog:index:v2", value: { schemaVersion: 2, revision: 7 } });
|
||||
expect(result.committed.receipt).toMatchObject({ id: "asset-catalog:migration:v1-to-v2", task: "M12-01G", status: "MIGRATED" });
|
||||
expect(result.committed.legacy).toEqual(result.baseline.legacy);
|
||||
});
|
||||
91
web/tests/e2e/asset-catalog-restart.spec.ts
Normal file
91
web/tests/e2e/asset-catalog-restart.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v1.json"),
|
||||
"utf8",
|
||||
)) as unknown;
|
||||
|
||||
test("M12-01H preserves catalog order and asset identity across page and Worker restarts", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const databaseName = `m12-01h-${Date.now()}`;
|
||||
const baseline = await page.evaluate(async ({ databaseName, source }) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: storage.ASSET_CATALOG_INDEX_V1_ID, value: source });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
const snapshot = await storage.loadAssetCatalogIndexedDBSnapshot(database);
|
||||
database.close();
|
||||
return snapshot;
|
||||
}, { databaseName, source: fixture });
|
||||
|
||||
await page.reload();
|
||||
const pageRestart = await page.evaluate(async (name) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const snapshot = await storage.loadAssetCatalogIndexedDBSnapshot(database);
|
||||
database.close();
|
||||
return snapshot;
|
||||
}, databaseName);
|
||||
|
||||
const workerSnapshot = async () => page.evaluate((name) => new Promise<unknown>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/asset-catalog-restart-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.ok ? resolve(event.data.snapshot) : reject(new Error(event.data.error));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ databaseName: name });
|
||||
}), databaseName);
|
||||
const firstWorker = await workerSnapshot();
|
||||
const secondWorker = await workerSnapshot();
|
||||
|
||||
expect(pageRestart).toEqual(baseline);
|
||||
expect(firstWorker).toEqual(baseline);
|
||||
expect(secondWorker).toEqual(baseline);
|
||||
expect(baseline).toEqual({
|
||||
schemaVersion: 1,
|
||||
revision: 7,
|
||||
manifestSha256: "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
|
||||
catalogOrder: [
|
||||
{ catalogId: "44444444-4444-4444-8444-444444444444", path: "Animation" },
|
||||
{ catalogId: "fe3ca14c-95d7-549f-a13b-1bb4c07b6074", path: "Characters" },
|
||||
{ catalogId: "b4bb3608-9267-5aba-bb9e-9e97b006d5ab", path: "Characters/Heroes" },
|
||||
],
|
||||
assetOrder: [
|
||||
"asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
|
||||
"asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
|
||||
],
|
||||
assetIdentities: [
|
||||
{
|
||||
assetId: "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier: "Action/Legacy Walk",
|
||||
},
|
||||
{
|
||||
assetId: "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier: "Object/Legacy Hero",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.evaluate((name) => indexedDB.deleteDatabase(name), databaseName);
|
||||
});
|
||||
113
web/tests/e2e/asset-preview-dedup.spec.ts
Normal file
113
web/tests/e2e/asset-preview-dedup.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v2.json"),
|
||||
"utf8",
|
||||
)) as Record<string, unknown>;
|
||||
const identity = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"),
|
||||
"utf8",
|
||||
)) as Record<string, unknown>;
|
||||
const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02E deduplicates preview content without merging asset metadata", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => {
|
||||
const storage = await import("/src/storage/asset-preview-opfs-commit.ts");
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const base = await storage.parseAssetCatalogManifestV2({
|
||||
...sourceCatalog,
|
||||
assets: (sourceCatalog.assets as Array<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const identities = [];
|
||||
for (const asset of base.assets) {
|
||||
const input = { ...sourceIdentity, assetId: asset.assetId } as Record<string, unknown>;
|
||||
delete input.identitySha256;
|
||||
identities.push(await storage.createAssetPreviewIdentity(input as never));
|
||||
}
|
||||
const databaseName = `m12-02e-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02e-${crypto.randomUUID()}`;
|
||||
const open = (): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = (transaction: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
let database = await open();
|
||||
const seed = database.transaction("setting", "readwrite");
|
||||
seed.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(seed);
|
||||
const first = await storage.commitAssetPreviewToOPFS(database, projectId, 7, identities[0], encoded.slice(0));
|
||||
const second = await storage.commitAssetPreviewToOPFS(database, projectId, 8, identities[1], encoded.slice(0));
|
||||
database.close();
|
||||
database = await open();
|
||||
const reopened = await storage.loadCommittedAssetPreviewCatalog(database);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let contentDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", identities[0].content.sha256.slice(0, 2)]) {
|
||||
contentDirectory = await contentDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
const files: string[] = [];
|
||||
for await (const [name, handle] of (contentDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) {
|
||||
if (handle.kind === "file") files.push(name);
|
||||
}
|
||||
files.sort();
|
||||
const stored = await (await contentDirectory.getFileHandle(identities[0].content.sha256)).getFile();
|
||||
const storedBytes = await stored.arrayBuffer();
|
||||
const storedSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", storedBytes)),
|
||||
(byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const withoutPreview = (asset: Record<string, unknown>) => {
|
||||
const copy = { ...asset };
|
||||
delete copy.preview;
|
||||
return copy;
|
||||
};
|
||||
const resultValue = {
|
||||
first: { revision: first.manifest.revision, deduplicated: first.deduplicated, identity: identities[0].identitySha256 },
|
||||
second: { revision: second.manifest.revision, deduplicated: second.deduplicated, identity: identities[1].identitySha256 },
|
||||
contentSha256: identities[0].content.sha256,
|
||||
files,
|
||||
storedBytes: stored.size,
|
||||
storedSha256,
|
||||
baseMetadata: base.assets.map((asset) => withoutPreview(asset as unknown as Record<string, unknown>)),
|
||||
committedMetadata: second.manifest.assets.map((asset) => withoutPreview(asset as unknown as Record<string, unknown>)),
|
||||
previews: second.manifest.assets.map((asset) => asset.preview),
|
||||
reopened: {
|
||||
revision: reopened.manifest.revision,
|
||||
receiptAssetId: reopened.receipt.assetId,
|
||||
preview: reopened.preview,
|
||||
},
|
||||
assetIds: base.assets.map((asset) => asset.assetId),
|
||||
};
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectId, { recursive: true });
|
||||
return resultValue;
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
expect(result.first).toMatchObject({ revision: 8, deduplicated: false });
|
||||
expect(result.second).toMatchObject({ revision: 9, deduplicated: true });
|
||||
expect(result.first.identity).not.toBe(result.second.identity);
|
||||
expect(result.files).toEqual([result.contentSha256]);
|
||||
expect(result.storedBytes).toBe(513);
|
||||
expect(result.storedSha256).toBe(result.contentSha256);
|
||||
expect(result.committedMetadata).toEqual(result.baseMetadata);
|
||||
expect(result.committedMetadata[0]).not.toEqual(result.committedMetadata[1]);
|
||||
expect(result.previews).toEqual([
|
||||
{ sha256: result.contentSha256, mimeType: "image/png", width: 8, height: 8, byteLength: 513 },
|
||||
{ sha256: result.contentSha256, mimeType: "image/png", width: 8, height: 8, byteLength: 513 },
|
||||
]);
|
||||
expect(result.reopened).toEqual({
|
||||
revision: 9,
|
||||
receiptAssetId: result.assetIds[1],
|
||||
preview: result.previews[1],
|
||||
});
|
||||
});
|
||||
95
web/tests/e2e/asset-preview-display.spec.ts
Normal file
95
web/tests/e2e/asset-preview-display.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/desktop-report.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/manifest.json"), "utf8"));
|
||||
const preview = fs.readFileSync(path.join(root, "tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02H main-thread and Offscreen preview displays match the Blender RGBA8 metrics", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ identityValue, bytes, desktopValue, thresholds }) => {
|
||||
const { displayAssetPreview } = await import("/src/assets/AssetPreviewDisplay.ts");
|
||||
const { compareRenderImages } = await import("/src/three-adapter/render-image-comparison.ts");
|
||||
document.body.replaceChildren();
|
||||
document.body.style.margin = "0";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "m12-asset-preview";
|
||||
canvas.setAttribute("aria-label", "Asset preview");
|
||||
canvas.style.width = "128px";
|
||||
canvas.style.height = "128px";
|
||||
canvas.style.imageRendering = "pixelated";
|
||||
document.body.append(canvas);
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const main = await displayAssetPreview(canvas, "MAIN_THREAD_CANVAS_2D", identityValue, encoded.slice(0));
|
||||
const workerResult = await new Promise<{ receipt: unknown; pixels: Uint8Array }>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/asset-preview-display-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.ok ? resolve(event.data) : reject(new Error(event.data.error));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ identity: identityValue, bytes: encoded.slice(0) });
|
||||
});
|
||||
const reference = new Uint8Array(desktopValue.pixelCount * 4);
|
||||
for (let offset = 0; offset < reference.byteLength; offset += 4) reference.set(desktopValue.referencePixel, offset);
|
||||
const mainMetrics = compareRenderImages(reference, main.pixels, desktopValue.width, desktopValue.height, thresholds);
|
||||
const offscreenPixels = new Uint8Array(workerResult.pixels);
|
||||
const offscreenMetrics = compareRenderImages(reference, offscreenPixels, desktopValue.width, desktopValue.height, thresholds);
|
||||
const wrong = new Uint8Array(main.pixels);
|
||||
wrong[0] ^= 0xff;
|
||||
const wrongMetrics = compareRenderImages(reference, wrong, desktopValue.width, desktopValue.height, thresholds);
|
||||
return {
|
||||
main: { receipt: main.receipt, pixels: Array.from(main.pixels) },
|
||||
offscreen: { receipt: workerResult.receipt, pixels: Array.from(offscreenPixels) },
|
||||
mainMetrics,
|
||||
offscreenMetrics,
|
||||
wrongMetrics,
|
||||
};
|
||||
}, { identityValue: identity, bytes: [...preview], desktopValue: desktop, thresholds: manifest.thresholds });
|
||||
|
||||
const canvas = page.locator("#m12-asset-preview");
|
||||
await expect(canvas).toBeVisible();
|
||||
await expect(canvas).toHaveAttribute("width", "8");
|
||||
await expect(canvas).toHaveAttribute("height", "8");
|
||||
await expect(canvas).toHaveAttribute("data-asset-preview-status", "ready");
|
||||
await expect(canvas).toHaveAttribute("data-asset-preview-backend", "MAIN_THREAD_CANVAS_2D");
|
||||
await expect(canvas).toHaveCSS("width", "128px");
|
||||
await expect(canvas).toHaveCSS("height", "128px");
|
||||
expect(result.main.receipt).toMatchObject({
|
||||
status: "READY",
|
||||
backend: "MAIN_THREAD_CANVAS_2D",
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixelByteLength: 256,
|
||||
pixelSha256: desktop.rgbaSha256,
|
||||
nonTransparentPixels: 64,
|
||||
bitmapClosed: true,
|
||||
});
|
||||
expect(result.offscreen.receipt).toMatchObject({
|
||||
status: "READY",
|
||||
backend: "OFFSCREEN_CANVAS_2D",
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixelByteLength: 256,
|
||||
pixelSha256: desktop.rgbaSha256,
|
||||
nonTransparentPixels: 64,
|
||||
bitmapClosed: true,
|
||||
});
|
||||
expect(result.main.pixels).toEqual(result.offscreen.pixels);
|
||||
for (const metrics of [result.mainMetrics, result.offscreenMetrics]) {
|
||||
expect(metrics.status).toBe("READY");
|
||||
expect(metrics.errorCode).toBeNull();
|
||||
expect(metrics.meanAbsoluteError).toBe(0);
|
||||
expect(metrics.rootMeanSquaredError).toBe(0);
|
||||
expect(metrics.p95ChannelError).toBe(0);
|
||||
expect(metrics.maxChannelError).toBe(0);
|
||||
expect(metrics.badPixelRatio).toBe(0);
|
||||
expect(metrics.foregroundIntersectionOverUnion).toBe(1);
|
||||
expect(metrics.alphaCoverageDeltaRatio).toBe(0);
|
||||
}
|
||||
expect(result.wrongMetrics.status).toBe("BLOCKED");
|
||||
expect(result.wrongMetrics.errorCode).toBe("RENDER_REFERENCE_MISMATCH");
|
||||
});
|
||||
171
web/tests/e2e/asset-preview-opfs-commit.spec.ts
Normal file
171
web/tests/e2e/asset-preview-opfs-commit.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v2.json"),
|
||||
"utf8",
|
||||
)) as Record<string, unknown>;
|
||||
const identity = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"),
|
||||
"utf8",
|
||||
)) as Record<string, unknown>;
|
||||
const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02D publishes a catalog preview only after verified OPFS persistence", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => {
|
||||
const storage = await import("/src/storage/asset-preview-opfs-commit.ts");
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const base = await storage.parseAssetCatalogManifestV2({
|
||||
...sourceCatalog,
|
||||
assets: (sourceCatalog.assets as Array<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const targetAssetId = base.assets[0].assetId;
|
||||
const identityInput = { ...sourceIdentity, assetId: targetAssetId } as Record<string, unknown>;
|
||||
delete identityInput.identitySha256;
|
||||
const boundIdentity = await storage.createAssetPreviewIdentity(identityInput as never);
|
||||
|
||||
const open = (name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = (transaction: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
const get = (store: IDBObjectStore, id: string): Promise<unknown> => new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const seed = async (database: IDBDatabase): Promise<void> => {
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(transaction);
|
||||
};
|
||||
const snapshot = async (database: IDBDatabase) => {
|
||||
const transaction = database.transaction("setting", "readonly");
|
||||
const store = transaction.objectStore("setting");
|
||||
const [index, receipt] = await Promise.all([
|
||||
get(store, "asset-catalog:index:v2"),
|
||||
get(store, storage.ASSET_PREVIEW_CATALOG_HEAD_ID),
|
||||
]);
|
||||
await complete(transaction);
|
||||
return { index, receipt };
|
||||
};
|
||||
const payloadExists = async (projectId: string): Promise<boolean> => {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", boundIdentity.content.sha256.slice(0, 2)]) {
|
||||
current = await current.getDirectoryHandle(segment);
|
||||
}
|
||||
const file = await (await current.getFileHandle(boundIdentity.content.sha256)).getFile();
|
||||
return file.size === encoded.byteLength;
|
||||
}
|
||||
catch { return false; }
|
||||
};
|
||||
const cleanupProject = async (projectId: string): Promise<void> => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
try {
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectId, { recursive: true });
|
||||
}
|
||||
catch { /* The before-write fault creates no project directory. */ }
|
||||
};
|
||||
const fault = async (faultAt: "BEFORE_OPFS_WRITE" | "AFTER_OPFS_WRITE" | "AFTER_CATALOG_PUT") => {
|
||||
const databaseName = `m12-02d-${faultAt.toLowerCase()}-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02d-${crypto.randomUUID()}`;
|
||||
let database = await open(databaseName);
|
||||
await seed(database);
|
||||
let code = "";
|
||||
try {
|
||||
await storage.commitAssetPreviewToOPFS(database, projectId, base.revision, boundIdentity, encoded.slice(0), { faultAt });
|
||||
}
|
||||
catch (error) { code = String((error as Error & { code?: string }).code ?? ""); }
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const state = await snapshot(database);
|
||||
const persisted = await payloadExists(projectId);
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
await cleanupProject(projectId);
|
||||
return { faultAt, code, state, persisted };
|
||||
};
|
||||
|
||||
const faults = [];
|
||||
for (const faultAt of ["BEFORE_OPFS_WRITE", "AFTER_OPFS_WRITE", "AFTER_CATALOG_PUT"] as const) {
|
||||
faults.push(await fault(faultAt));
|
||||
}
|
||||
|
||||
const databaseName = `m12-02d-success-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02d-${crypto.randomUUID()}`;
|
||||
let database = await open(databaseName);
|
||||
await seed(database);
|
||||
const committed = await storage.commitAssetPreviewToOPFS(database, projectId, base.revision, boundIdentity, encoded.slice(0));
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const reopened = await storage.loadCommittedAssetPreviewCatalog(database);
|
||||
const successState = await snapshot(database);
|
||||
const persisted = await payloadExists(projectId);
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
await cleanupProject(projectId);
|
||||
return {
|
||||
base,
|
||||
targetAssetId,
|
||||
identitySha256: boundIdentity.identitySha256,
|
||||
contentSha256: boundIdentity.content.sha256,
|
||||
faults,
|
||||
committed: {
|
||||
revision: committed.manifest.revision,
|
||||
preview: committed.preview,
|
||||
receipt: committed.receipt,
|
||||
deduplicated: committed.deduplicated,
|
||||
},
|
||||
reopened: {
|
||||
revision: reopened.manifest.revision,
|
||||
preview: reopened.preview,
|
||||
receipt: reopened.receipt,
|
||||
},
|
||||
successState,
|
||||
persisted,
|
||||
};
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
for (const failure of result.faults) {
|
||||
expect(failure.code).toBe("STORAGE_TRANSACTION");
|
||||
expect(failure.state.index).toEqual({ id: "asset-catalog:index:v2", value: result.base });
|
||||
expect(failure.state.receipt).toBeUndefined();
|
||||
}
|
||||
expect(result.faults.map((failure) => failure.persisted)).toEqual([false, true, true]);
|
||||
expect(result.persisted).toBe(true);
|
||||
expect(result.committed.revision).toBe(8);
|
||||
expect(result.committed.deduplicated).toBe(false);
|
||||
expect(result.committed.preview).toEqual({
|
||||
sha256: result.contentSha256,
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
byteLength: 513,
|
||||
});
|
||||
expect(result.committed.receipt).toMatchObject({
|
||||
task: "M12-02D",
|
||||
status: "COMMITTED",
|
||||
assetId: result.targetAssetId,
|
||||
baseRevision: 7,
|
||||
committedRevision: 8,
|
||||
previewIdentitySha256: result.identitySha256,
|
||||
contentSha256: result.contentSha256,
|
||||
});
|
||||
expect(result.reopened).toEqual({
|
||||
revision: result.committed.revision,
|
||||
preview: result.committed.preview,
|
||||
receipt: result.committed.receipt,
|
||||
});
|
||||
expect(result.successState.receipt).toEqual(result.committed.receipt);
|
||||
});
|
||||
118
web/tests/e2e/asset-preview-quarantine.spec.ts
Normal file
118
web/tests/e2e/asset-preview-quarantine.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v2.json"), "utf8")) as Record<string, unknown>;
|
||||
const identity = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"), "utf8")) as Record<string, unknown>;
|
||||
const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02F quarantines corrupt preview bytes while preserving catalog metadata", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => {
|
||||
const commit = await import("/src/storage/asset-preview-opfs-commit.ts");
|
||||
const quarantine = await import("/src/storage/asset-preview-quarantine.ts");
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const base = await commit.parseAssetCatalogManifestV2({
|
||||
...sourceCatalog,
|
||||
assets: (sourceCatalog.assets as Array<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const target = base.assets[0];
|
||||
const identityInput = { ...sourceIdentity, assetId: target.assetId } as Record<string, unknown>;
|
||||
delete identityInput.identitySha256;
|
||||
const boundIdentity = await commit.createAssetPreviewIdentity(identityInput as never);
|
||||
const databaseName = `m12-02f-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02f-${crypto.randomUUID()}`;
|
||||
const open = (): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = (transaction: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
let database = await open();
|
||||
const seed = database.transaction("setting", "readwrite");
|
||||
seed.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(seed);
|
||||
const committed = await commit.commitAssetPreviewToOPFS(database, projectId, 7, boundIdentity, encoded.slice(0));
|
||||
const ready = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let contentDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", boundIdentity.content.sha256.slice(0, 2)]) {
|
||||
contentDirectory = await contentDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
const fileHandle = await contentDirectory.getFileHandle(boundIdentity.content.sha256);
|
||||
const corrupt = new Uint8Array(await (await fileHandle.getFile()).arrayBuffer());
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
const writer = await fileHandle.createWritable();
|
||||
await writer.write(corrupt);
|
||||
await writer.close();
|
||||
|
||||
const quarantined = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
let contentExists = true;
|
||||
try { await contentDirectory.getFileHandle(boundIdentity.content.sha256); }
|
||||
catch { contentExists = false; }
|
||||
database.close();
|
||||
database = await open();
|
||||
const reopened = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
const catalogTransaction = database.transaction("setting", "readonly");
|
||||
const catalogRow = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const request = catalogTransaction.objectStore("setting").get("asset-catalog:index:v2");
|
||||
request.onsuccess = () => resolve(request.result as Record<string, unknown>);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await complete(catalogTransaction);
|
||||
const storedCatalog = await commit.parseAssetCatalogManifestV2(catalogRow.value);
|
||||
const quarantinePath = quarantined.status === "QUARANTINED" ? quarantined.receipt.quarantinePath : null;
|
||||
let quarantineFileBytes = 0;
|
||||
if (quarantinePath) {
|
||||
let current = root;
|
||||
const segments = quarantinePath.split("/");
|
||||
for (const segment of segments.slice(0, -1)) current = await current.getDirectoryHandle(segment);
|
||||
quarantineFileBytes = (await (await current.getFileHandle(segments.at(-1)!)).getFile()).size;
|
||||
}
|
||||
const value = {
|
||||
baseMetadata: target,
|
||||
ready: { status: ready.status, bytes: ready.status === "READY" ? ready.data.byteLength : 0 },
|
||||
quarantined,
|
||||
reopened,
|
||||
contentExists,
|
||||
quarantineFileBytes,
|
||||
storedAsset: storedCatalog.assets[0],
|
||||
storedRevision: storedCatalog.revision,
|
||||
committedRevision: committed.manifest.revision,
|
||||
otherAsset: storedCatalog.assets[1],
|
||||
baseOtherAsset: base.assets[1],
|
||||
};
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectId, { recursive: true });
|
||||
return value;
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
expect(result.ready).toEqual({ status: "READY", bytes: 513 });
|
||||
expect(result.quarantined.status).toBe("QUARANTINED");
|
||||
expect(result.quarantined.preview).toBeNull();
|
||||
expect(result.quarantined.data).toBeNull();
|
||||
expect(result.quarantined.receipt).toMatchObject({
|
||||
task: "M12-02F",
|
||||
status: "QUARANTINED",
|
||||
catalogRevision: 8,
|
||||
actualByteLength: 513,
|
||||
code: "ASSET_SOURCE_HASH_MISMATCH",
|
||||
});
|
||||
expect(result.quarantined.receipt.actualSha256).not.toBe(result.quarantined.receipt.expected.sha256);
|
||||
expect(result.quarantined.receipt.quarantinePath).toContain("/assets/quarantine/");
|
||||
expect(result.reopened).toEqual(result.quarantined);
|
||||
expect(result.contentExists).toBe(false);
|
||||
expect(result.quarantineFileBytes).toBe(513);
|
||||
expect(result.storedRevision).toBe(result.committedRevision);
|
||||
expect(result.storedAsset).toEqual(result.quarantined.asset);
|
||||
expect({ ...result.storedAsset, preview: null }).toEqual(result.baseMetadata);
|
||||
expect(result.otherAsset).toEqual(result.baseOtherAsset);
|
||||
});
|
||||
132
web/tests/e2e/asset-preview-reference-gc.spec.ts
Normal file
132
web/tests/e2e/asset-preview-reference-gc.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v2.json"), "utf8")) as Record<string, unknown>;
|
||||
const identity = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"), "utf8")) as Record<string, unknown>;
|
||||
const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02G reclaims only the final project-scoped preview payload reference", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => {
|
||||
const commit = await import("/src/storage/asset-preview-opfs-commit.ts");
|
||||
const gc = await import("/src/storage/asset-preview-reference-gc.ts");
|
||||
const quarantine = await import("/src/storage/asset-preview-quarantine.ts");
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const base = await commit.parseAssetCatalogManifestV2({
|
||||
...sourceCatalog,
|
||||
assets: (sourceCatalog.assets as Array<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const identityFor = async (assetId: string) => {
|
||||
const input = { ...sourceIdentity, assetId } as Record<string, unknown>;
|
||||
delete input.identitySha256;
|
||||
return commit.createAssetPreviewIdentity(input as never);
|
||||
};
|
||||
const identities = await Promise.all(base.assets.map((asset) => identityFor(asset.assetId)));
|
||||
const open = (name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = (transaction: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
const seed = async (database: IDBDatabase) => {
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(transaction);
|
||||
};
|
||||
const exists = async (projectId: string, sha256: string): Promise<boolean> => {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", sha256.slice(0, 2)]) current = await current.getDirectoryHandle(segment);
|
||||
await current.getFileHandle(sha256);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
};
|
||||
const databaseAName = `m12-02g-a-${crypto.randomUUID()}`;
|
||||
const databaseBName = `m12-02g-b-${crypto.randomUUID()}`;
|
||||
const projectA = `m12-02g-a-${crypto.randomUUID()}`;
|
||||
const projectB = `m12-02g-b-${crypto.randomUUID()}`;
|
||||
let databaseA = await open(databaseAName);
|
||||
let databaseB = await open(databaseBName);
|
||||
await seed(databaseA);
|
||||
await seed(databaseB);
|
||||
await commit.commitAssetPreviewToOPFS(databaseA, projectA, 7, identities[0], encoded.slice(0));
|
||||
await commit.commitAssetPreviewToOPFS(databaseA, projectA, 8, identities[1], encoded.slice(0));
|
||||
await commit.commitAssetPreviewToOPFS(databaseB, projectB, 7, identities[0], encoded.slice(0));
|
||||
|
||||
const firstRemoval = await gc.removeAssetPreviewReference(databaseA, projectA, 9, base.assets[0].assetId);
|
||||
const afterFirstA = await exists(projectA, identities[0].content.sha256);
|
||||
const afterFirstB = await exists(projectB, identities[0].content.sha256);
|
||||
const finalRemoval = await gc.removeAssetPreviewReference(databaseA, projectA, 10, base.assets[1].assetId);
|
||||
const afterFinalA = await exists(projectA, identities[0].content.sha256);
|
||||
const afterFinalB = await exists(projectB, identities[0].content.sha256);
|
||||
const projectBReady = await quarantine.inspectAssetPreviewWithQuarantine(databaseB, projectB, base.assets[0].assetId);
|
||||
|
||||
databaseA.close();
|
||||
databaseA = await open(databaseAName);
|
||||
const transaction = databaseA.transaction("setting", "readonly");
|
||||
const row = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get("asset-catalog:index:v2");
|
||||
request.onsuccess = () => resolve(request.result as Record<string, unknown>);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await complete(transaction);
|
||||
const reopenedA = await commit.parseAssetCatalogManifestV2(row.value);
|
||||
const value = {
|
||||
firstReceipt: firstRemoval.receipt,
|
||||
finalReceipt: finalRemoval.receipt,
|
||||
afterFirstA,
|
||||
afterFirstB,
|
||||
afterFinalA,
|
||||
afterFinalB,
|
||||
projectBReady: { status: projectBReady.status, bytes: projectBReady.status === "READY" ? projectBReady.data.byteLength : 0 },
|
||||
reopenedRevision: reopenedA.revision,
|
||||
reopenedPreviews: reopenedA.assets.map((asset) => asset.preview),
|
||||
baseMetadata: base.assets,
|
||||
reopenedMetadata: reopenedA.assets.map((asset) => ({ ...asset, preview: null })),
|
||||
};
|
||||
databaseA.close();
|
||||
databaseB.close();
|
||||
indexedDB.deleteDatabase(databaseAName);
|
||||
indexedDB.deleteDatabase(databaseBName);
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectA, { recursive: true });
|
||||
await projects.removeEntry(projectB, { recursive: true });
|
||||
return value;
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
expect(result.firstReceipt).toMatchObject({
|
||||
task: "M12-02G",
|
||||
status: "RETAINED",
|
||||
baseRevision: 9,
|
||||
committedRevision: 10,
|
||||
remainingProjectReferences: 1,
|
||||
payloadReclaimed: false,
|
||||
});
|
||||
expect(result.finalReceipt).toMatchObject({
|
||||
task: "M12-02G",
|
||||
status: "COLLECTED",
|
||||
baseRevision: 10,
|
||||
committedRevision: 11,
|
||||
remainingProjectReferences: 0,
|
||||
payloadReclaimed: true,
|
||||
});
|
||||
expect({
|
||||
afterFirstA: result.afterFirstA,
|
||||
afterFirstB: result.afterFirstB,
|
||||
afterFinalA: result.afterFinalA,
|
||||
afterFinalB: result.afterFinalB,
|
||||
}).toEqual({ afterFirstA: true, afterFirstB: true, afterFinalA: false, afterFinalB: true });
|
||||
expect(result.projectBReady).toEqual({ status: "READY", bytes: 513 });
|
||||
expect(result.reopenedRevision).toBe(11);
|
||||
expect(result.reopenedPreviews).toEqual([null, null]);
|
||||
expect(result.reopenedMetadata).toEqual(result.baseMetadata);
|
||||
});
|
||||
139
web/tests/e2e/library-append-main.spec.ts
Normal file
139
web/tests/e2e/library-append-main.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { computeLibraryAppendClosureSha256, LIBRARY_MAIN_APPEND_SCHEMA } from "../../protocol/library-main-append";
|
||||
import { createLibraryOperationBinding, createLibrarySourceIdentity, LIBRARY_OPERATION_IDENTITY_SCHEMA } from "../../protocol/library-operation-identity";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_library_append_v1/m12_append_source.blend"));
|
||||
const target = fs.readFileSync(path.join(root, "tests/files/web/empty.blend"));
|
||||
|
||||
test("M12-03D appends one fully-local dependency closure through WASM Main", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const closure = {
|
||||
object: "Object/M12 Append Object",
|
||||
mesh: "Mesh/M12 Append Mesh",
|
||||
material: "Material/M12 Append Material",
|
||||
image: "Image/M12 Append Image",
|
||||
} as const;
|
||||
const sourceSha256 = crypto.createHash("sha256").update(source).digest("hex");
|
||||
const sourceIdentity = await createLibrarySourceIdentity({
|
||||
sourceLocator: "project-assets/libraries/m12_append_source.blend",
|
||||
sourceSha256,
|
||||
});
|
||||
const dependencyClosureSha256 = await computeLibraryAppendClosureSha256(closure);
|
||||
const binding = await createLibraryOperationBinding({
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
operation: "APPEND",
|
||||
source: sourceIdentity,
|
||||
sourceDataBlockId: closure.object,
|
||||
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03d", localDataBlockId: closure.object },
|
||||
readOnly: false,
|
||||
referenceReadOnly: false,
|
||||
sourceGeneration: 1,
|
||||
sourceRevision: 0,
|
||||
dependencyClosureSha256,
|
||||
});
|
||||
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const sourceBuffer = Uint8Array.from(sourceBytes).buffer;
|
||||
const targetBuffer = Uint8Array.from(targetBytes).buffer;
|
||||
const digest = async (bytes: ArrayBuffer): Promise<string> => {
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const makeRequest = (baseRevision: number) => ({
|
||||
schemaVersion: 1 as const,
|
||||
baseRevision,
|
||||
binding,
|
||||
expectedClosure: closure,
|
||||
});
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopened = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const ids = {
|
||||
object: "object:M12 Append Object",
|
||||
mesh: "mesh:M12 Append Mesh",
|
||||
material: "material:M12 Append Material",
|
||||
image: "image:M12 Append Image",
|
||||
};
|
||||
const closureState = (snapshot: any) => ({
|
||||
object: snapshot.nodes.find((item: any) => item.id === ids.object),
|
||||
mesh: snapshot.meshes.find((item: any) => item.id === ids.mesh),
|
||||
material: snapshot.materials.find((item: any) => item.id === ids.material),
|
||||
image: snapshot.images.find((item: any) => item.id === ids.image),
|
||||
});
|
||||
try {
|
||||
const opened = await client.openBlend(targetBuffer.slice(0));
|
||||
const baseRevision = opened.snapshot.revision;
|
||||
const request = await makeRequest(baseRevision);
|
||||
const appended = await client.appendLibraryObject(sourceBuffer.slice(0), request);
|
||||
const appendedClosure = closureState(appended.snapshot);
|
||||
const stale = await client.appendLibraryObject(sourceBuffer.slice(0), makeRequest(baseRevision))
|
||||
.then(() => ({ code: "NO_ERROR" }))
|
||||
.catch((error: any) => ({ code: error.code, message: error.message }));
|
||||
const collisionRequest = await makeRequest(appended.snapshot.revision);
|
||||
const collision = await client.appendLibraryObject(sourceBuffer.slice(0), collisionRequest)
|
||||
.then(() => ({ code: "NO_ERROR" }))
|
||||
.catch((error: any) => ({ code: error.code, message: error.message }));
|
||||
const afterCollision = await client.snapshot();
|
||||
const undone = await client.applyCommand({ type: "undo" });
|
||||
const redone = await client.applyCommand({ type: "redo" });
|
||||
const saved = await client.saveBlend();
|
||||
const reopenedResult = await reopened.openBlend(saved);
|
||||
const reopenedClosure = closureState(reopenedResult.snapshot);
|
||||
return {
|
||||
sourceSha256: await digest(sourceBuffer),
|
||||
baseRevision,
|
||||
appendedRevision: appended.snapshot.revision,
|
||||
receipt: appended.receipt,
|
||||
delta: appended.delta,
|
||||
appendedClosure: {
|
||||
object: appendedClosure.object && { type: appendedClosure.object.type, dataId: appendedClosure.object.dataId },
|
||||
mesh: appendedClosure.mesh && { vertexCount: appendedClosure.mesh.vertexCount, materialSlotIds: appendedClosure.mesh.materialSlotIds },
|
||||
material: appendedClosure.material && {
|
||||
imageIds: appendedClosure.material.imageIds ?? appendedClosure.material.nodes?.flatMap((node: any) => node.imageId ? [node.imageId] : []),
|
||||
},
|
||||
image: appendedClosure.image && { libraryLinked: appendedClosure.image.libraryLinked, width: appendedClosure.image.width, height: appendedClosure.image.height },
|
||||
},
|
||||
stale,
|
||||
collision,
|
||||
collisionRevision: afterCollision.snapshot.revision,
|
||||
undoHasObject: Boolean(closureState(undone.snapshot).object),
|
||||
redoHasObject: Boolean(closureState(redone.snapshot).object),
|
||||
reopenedRevision: reopenedResult.snapshot.revision,
|
||||
reopenedHasObject: Boolean(reopenedClosure.object),
|
||||
reopenedHasLocalImage: reopenedClosure.image?.libraryLinked === false,
|
||||
};
|
||||
}
|
||||
catch (error: any) {
|
||||
return { error: { code: error?.code, message: error?.message, detail: error?.detail } };
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
reopened.terminate();
|
||||
}
|
||||
}, { sourceBytes: Array.from(source), targetBytes: Array.from(target), closure, binding });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.appendedRevision).toBe(result.baseRevision + 1);
|
||||
expect(result.receipt).toMatchObject({ operation: "APPEND", transactionCount: 1, baseRevision: result.baseRevision, nextRevision: result.appendedRevision });
|
||||
expect(result.receipt.mapping).toHaveLength(4);
|
||||
expect(result.receipt.mapping.every((item: any) => item.owner === "LOCAL_MAIN" && item.readOnly === false && item.source === item.local)).toBe(true);
|
||||
expect(result.delta).toMatchObject({ schemaVersion: 1, baseRevision: result.baseRevision, nextRevision: result.appendedRevision });
|
||||
expect(result.appendedClosure).toEqual({
|
||||
object: { type: "MESH", dataId: "mesh:M12 Append Mesh" },
|
||||
mesh: { vertexCount: 4, materialSlotIds: ["material:M12 Append Material"] },
|
||||
material: { imageIds: ["image:M12 Append Image"] },
|
||||
image: { libraryLinked: false, width: 2, height: 2 },
|
||||
});
|
||||
expect(result.stale.code).toBe("REVISION_CONFLICT");
|
||||
expect(result.collision.code).toBe("ASSET_MANIFEST_INVALID");
|
||||
expect(result.collisionRevision).toBe(result.appendedRevision);
|
||||
expect(result.undoHasObject).toBe(false);
|
||||
expect(result.redoHasObject).toBe(true);
|
||||
expect(result.reopenedHasObject).toBe(true);
|
||||
expect(result.reopenedHasLocalImage).toBe(true);
|
||||
});
|
||||
72
web/tests/e2e/render-compositor-media-recovery.spec.ts
Normal file
72
web/tests/e2e/render-compositor-media-recovery.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-14/render-compositor-media-recovery.json"), "utf8")) as {
|
||||
domains: Array<{
|
||||
domain: "RENDER" | "COMPOSITOR" | "MEDIA";
|
||||
fixture: string;
|
||||
sha256: string;
|
||||
cancellationCode: string;
|
||||
budgetCode: string;
|
||||
}>;
|
||||
textureFixture: { path: string; sha256: string };
|
||||
};
|
||||
const fixtures = Object.fromEntries(manifest.domains.map((item) => [item.domain, fs.readFileSync(path.join(root, item.fixture))]));
|
||||
const texture = fs.readFileSync(path.join(root, manifest.textureFixture.path));
|
||||
const sha256 = (value: Buffer) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
test("M11-14 recovers real render, compositor and media production paths after lifecycle faults", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
for (const item of manifest.domains) expect(sha256(fixtures[item.domain])).toBe(item.sha256);
|
||||
expect(sha256(texture)).toBe(manifest.textureFixture.sha256);
|
||||
await page.goto("/");
|
||||
const reports = await page.evaluate(async ({ renderBlend, compositorBlend, movie, texturePng }) => {
|
||||
const { runRenderCompositorMediaRecoverySuite } = await import("/src/testing/render-compositor-media-recovery.ts");
|
||||
return runRenderCompositorMediaRecoverySuite({
|
||||
renderBlend: Uint8Array.from(renderBlend).buffer,
|
||||
compositorBlend: Uint8Array.from(compositorBlend).buffer,
|
||||
movie: { mimeType: "video/mp4", data: Uint8Array.from(movie).buffer },
|
||||
texturePng: Uint8Array.from(texturePng).buffer,
|
||||
});
|
||||
}, {
|
||||
renderBlend: Array.from(fixtures.RENDER),
|
||||
compositorBlend: Array.from(fixtures.COMPOSITOR),
|
||||
movie: Array.from(fixtures.MEDIA),
|
||||
texturePng: Array.from(texture),
|
||||
});
|
||||
|
||||
expect(reports.map((report) => report.domain)).toEqual(["RENDER", "COMPOSITOR", "MEDIA"]);
|
||||
for (const [index, report] of reports.entries()) {
|
||||
const expected = manifest.domains[index];
|
||||
expect(report.source.sha256).toBe(expected.sha256);
|
||||
expect(report.cancellation).toEqual({
|
||||
status: "CANCELLED",
|
||||
code: expected.cancellationCode,
|
||||
publishedResults: 0,
|
||||
temporaryResourcesAfter: 0,
|
||||
});
|
||||
expect(report.restart).toMatchObject({ status: "RECOVERED", generationBefore: 1, generationAfter: 2 });
|
||||
expect(report.restart.identityAfter).toBe(report.restart.identityBefore);
|
||||
expect(report.restart.outputSha256After).toBe(report.restart.outputSha256Before);
|
||||
expect(report.budget).toEqual({
|
||||
status: "BLOCKED",
|
||||
code: expected.budgetCode,
|
||||
retainedIdentityHash: report.restart.identityAfter,
|
||||
temporaryResourcesAfter: 0,
|
||||
});
|
||||
expect(report.release.status).toBe("RELEASED");
|
||||
expect(report.release.releasedBytes).toBeGreaterThan(0);
|
||||
expect(report.release.releasedResources).toBeGreaterThan(0);
|
||||
expect(report.release.resourcesAfter).toBe(0);
|
||||
expect(report.recovery).toMatchObject({
|
||||
status: "RECOVERED",
|
||||
identityHash: report.restart.identityAfter,
|
||||
outputSha256: report.restart.outputSha256After,
|
||||
});
|
||||
expect(report.recovery.outputBytes).toBeGreaterThan(0);
|
||||
expect(report.recovery.visibleUnits).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
64
web/tests/unit/asset-catalog-compatibility.test.mjs
Normal file
64
web/tests/unit/asset-catalog-compatibility.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-compatibility-unit-"));
|
||||
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-compatibility.ts"];
|
||||
for (const sourceName of sources) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"'));
|
||||
}
|
||||
const compatibility = await import(pathToFileURL(path.join(temporary, "asset-catalog-compatibility.mjs")));
|
||||
const v1 = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v1.json"), "utf8"));
|
||||
const v2 = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v2.json"), "utf8"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01E/compatibility-report.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01E/manifest.json"), "utf8"));
|
||||
|
||||
test("M12-01E binds the compatibility policy and generated reports", () => {
|
||||
assert.equal(manifest.nextTask, "M12-01F");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const actual = crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, artifact.path))).digest("hex");
|
||||
assert.equal(actual, artifact.sha256, `${artifact.path} hash drifted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-01E exposes schema v2 to a v1 reader as a bounded read-only snapshot", async () => {
|
||||
const before = structuredClone(v2);
|
||||
const report = await compatibility.inspectAssetCatalogForLegacyReader(v2, "READ");
|
||||
assert.deepEqual(report, golden.read);
|
||||
assert.equal(report.status, "READ_ONLY");
|
||||
assert.equal(report.code, "ASSET_SCHEMA_DOWNGRADE_BLOCKED");
|
||||
assert.deepEqual(report.snapshot.catalogs.map((item) => item.path), ["Animation", "Characters", "Characters/Heroes"]);
|
||||
assert.deepEqual(v2, before);
|
||||
});
|
||||
|
||||
test("M12-01E blocks every legacy write and save without changing the source hash", async () => {
|
||||
for (const [operation, key] of [["CATALOG_WRITE", "catalogWrite"], ["ASSET_WRITE", "assetWrite"], ["SAVE", "save"]]) {
|
||||
const report = await compatibility.inspectAssetCatalogForLegacyReader(v2, operation);
|
||||
assert.deepEqual(report, golden[key]);
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.equal(report.code, "ASSET_SCHEMA_DOWNGRADE_BLOCKED");
|
||||
assert.equal(report.sourceSha256, golden.read.sourceSha256);
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-01E keeps native v1 ready and blocks unknown future schemas", async () => {
|
||||
assert.equal((await compatibility.inspectAssetCatalogForLegacyReader(v1, "SAVE")).status, "READY");
|
||||
assert.deepEqual(await compatibility.inspectAssetCatalogForLegacyReader({ schemaVersion: 3 }, "READ"), golden.future);
|
||||
assert.equal(golden.future.code, "PROTOCOL_MISMATCH");
|
||||
assert.equal(golden.future.recoverable, false);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
22
web/tests/unit/asset-catalog-indexeddb-migration.test.mjs
Normal file
22
web/tests/unit/asset-catalog-indexeddb-migration.test.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-01G/manifest.json"), "utf8"));
|
||||
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-01G binds the production transaction, browser suite, and migration inputs", () => {
|
||||
assert.equal(manifest.task, "M12-01G");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-01H");
|
||||
assert.deepEqual(manifest.transaction.faultPoints, ["AFTER_TARGET_PUT", "AFTER_SOURCE_DELETE"]);
|
||||
assert.equal(manifest.transaction.failureCode, "STORAGE_TRANSACTION");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
60
web/tests/unit/asset-catalog-migration.test.mjs
Normal file
60
web/tests/unit/asset-catalog-migration.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-migration-unit-"));
|
||||
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-migration.ts"];
|
||||
for (const sourceName of sources) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
const output = result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"');
|
||||
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), output);
|
||||
}
|
||||
const migration = await import(pathToFileURL(path.join(temporary, "asset-catalog-migration.mjs")));
|
||||
const source = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v1.json"), "utf8"));
|
||||
const target = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v2.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/migration-report.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/manifest.json"), "utf8"));
|
||||
|
||||
test("M12-01D binds migration inputs, outputs, and production protocols", () => {
|
||||
assert.equal(manifest.nextTask, "M12-01E");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const actual = crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, artifact.path))).digest("hex");
|
||||
assert.equal(actual, artifact.sha256, `${artifact.path} hash drifted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-01D migrates schema v1 to v2 without dropping source, preview, or library data", async () => {
|
||||
const actual = await migration.migrateAssetCatalogV1ToV2(source);
|
||||
assert.deepEqual(actual.manifest, target);
|
||||
assert.deepEqual(actual.report, report);
|
||||
assert.equal(actual.manifest.revision, 7);
|
||||
assert.deepEqual(actual.manifest.catalogs.map((item) => item.path), ["Animation", "Characters", "Characters/Heroes"]);
|
||||
assert.equal(actual.manifest.assets.filter((item) => item.preview !== null).length, 1);
|
||||
assert.deepEqual(actual.manifest.libraries.map((item) => item.libraryId), ["library:characters", "library:materials"]);
|
||||
assert.equal(actual.report.preserved.sourceBindings, 2);
|
||||
});
|
||||
|
||||
test("M12-01D preserves canonical UUIDs and deterministically maps legacy IDs", async () => {
|
||||
const first = await migration.migrateAssetCatalogV1ToV2(source);
|
||||
const second = await migration.migrateAssetCatalogV1ToV2(structuredClone(source));
|
||||
assert.deepEqual(second, first);
|
||||
const canonical = first.report.catalogMappings.find((item) => item.legacyId.startsWith("4444"));
|
||||
assert.equal(canonical.catalogId, canonical.legacyId);
|
||||
assert.match(first.report.catalogMappings.find((item) => item.legacyId === "catalog:root").catalogId, /^[a-f0-9-]{36}$/);
|
||||
assert.equal(first.report.sourceManifestSha256, report.sourceManifestSha256);
|
||||
assert.equal(first.report.targetManifestSha256, report.targetManifestSha256);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
89
web/tests/unit/asset-catalog-negatives.test.mjs
Normal file
89
web/tests/unit/asset-catalog-negatives.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-negatives-unit-"));
|
||||
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-migration.ts"];
|
||||
for (const sourceName of sources) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"'));
|
||||
}
|
||||
const v2Protocol = await import(pathToFileURL(path.join(temporary, "asset-catalog-v2.mjs")));
|
||||
const migration = await import(pathToFileURL(path.join(temporary, "asset-catalog-migration.mjs")));
|
||||
const v1 = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v1.json"), "utf8"));
|
||||
const v2 = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01D/catalog-v2.json"), "utf8"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01F/negative-cases.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01F/manifest.json"), "utf8"));
|
||||
|
||||
test("M12-01F binds every negative input contract", () => {
|
||||
assert.equal(manifest.negativeCaseCount, golden.cases.length);
|
||||
assert.equal(manifest.nextTask, "M12-01G");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const actual = crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, artifact.path))).digest("hex");
|
||||
assert.equal(actual, artifact.sha256, `${artifact.path} hash drifted`);
|
||||
}
|
||||
});
|
||||
|
||||
async function codeFrom(run) {
|
||||
try {
|
||||
await run();
|
||||
return "NO_ERROR";
|
||||
}
|
||||
catch (error) {
|
||||
return error?.code ?? "UNKNOWN_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
test("M12-01F rejects duplicate identities, cycles, budgets, and unknown fields", async () => {
|
||||
const duplicateCatalog = structuredClone(v2);
|
||||
duplicateCatalog.catalogs.push(structuredClone(duplicateCatalog.catalogs[0]));
|
||||
const duplicateAsset = structuredClone(v2);
|
||||
duplicateAsset.assets.push(structuredClone(duplicateAsset.assets[0]));
|
||||
const legacyCycle = structuredClone(v1);
|
||||
legacyCycle.catalogs[0].parentId = legacyCycle.catalogs[1].id;
|
||||
const parentMismatch = structuredClone(v2);
|
||||
parentMismatch.catalogs[0].parentPath = "Characters";
|
||||
const simpleName = structuredClone(v2);
|
||||
simpleName.catalogs[0].simpleName = "\u00e9".repeat(32);
|
||||
const tag = structuredClone(v2);
|
||||
tag.assets[0].tags = ["\u00e9".repeat(32)];
|
||||
tag.assets[0].activeTag = 0;
|
||||
const unknownTop = { ...structuredClone(v2), future: true };
|
||||
const unknownAsset = structuredClone(v2);
|
||||
unknownAsset.assets[0].future = true;
|
||||
const unknownLegacyAsset = structuredClone(v1);
|
||||
unknownLegacyAsset.assets[0].future = true;
|
||||
|
||||
const cases = [
|
||||
["DUPLICATE_CATALOG_ID", () => v2Protocol.parseAssetCatalogManifestV2(duplicateCatalog)],
|
||||
["DUPLICATE_ASSET_ID", () => v2Protocol.parseAssetCatalogManifestV2(duplicateAsset)],
|
||||
["LEGACY_PARENT_CYCLE", () => migration.migrateAssetCatalogV1ToV2(legacyCycle)],
|
||||
["PARENT_PATH_MISMATCH", () => v2Protocol.parseAssetCatalogManifestV2(parentMismatch)],
|
||||
["OVERLONG_SIMPLE_NAME_UTF8", () => v2Protocol.parseAssetCatalogManifestV2(simpleName)],
|
||||
["OVERLONG_TAG_UTF8", () => v2Protocol.parseAssetCatalogManifestV2(tag)],
|
||||
["V2_UNKNOWN_TOP_LEVEL", () => v2Protocol.parseAssetCatalogManifestV2(unknownTop)],
|
||||
["V2_UNKNOWN_ASSET_FIELD", () => v2Protocol.parseAssetCatalogManifestV2(unknownAsset)],
|
||||
["V1_UNKNOWN_ASSET_FIELD", () => migration.migrateAssetCatalogV1ToV2(unknownLegacyAsset)],
|
||||
];
|
||||
const actual = [];
|
||||
for (const [id, run] of cases) {
|
||||
actual.push({ id, code: await codeFrom(run) });
|
||||
assert.deepEqual(await v2Protocol.parseAssetCatalogManifestV2(v2), v2, `${id} poisoned the valid parser path`);
|
||||
}
|
||||
assert.deepEqual(actual, golden.cases);
|
||||
assert.equal(golden.nextTask, "M12-01G");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
23
web/tests/unit/asset-catalog-restart.test.mjs
Normal file
23
web/tests/unit/asset-catalog-restart.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-01H/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-01H binds page and Worker restart identity evidence", () => {
|
||||
assert.equal(manifest.task, "M12-01H");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-01I");
|
||||
assert.deepEqual(manifest.restart.contexts, ["INITIAL_PAGE", "RELOADED_PAGE", "WORKER_GENERATION_1", "WORKER_GENERATION_2"]);
|
||||
assert.equal(manifest.restart.catalogOrder.length, manifest.restart.catalogCount);
|
||||
assert.equal(manifest.restart.assetOrder.length, manifest.restart.assetCount);
|
||||
assert.match(manifest.restart.manifestSha256, /^[a-f0-9]{64}$/);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
70
web/tests/unit/asset-catalog-v2.test.mjs
Normal file
70
web/tests/unit/asset-catalog-v2.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/asset-catalog-v2.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-v2-unit-"));
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "asset-catalog-v2.mjs"), result.outputText);
|
||||
const catalog = await import(pathToFileURL(path.join(temporary, "asset-catalog-v2.mjs")));
|
||||
const desktop = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01B/canonical.json"), "utf8"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01C/asset-catalog-v2.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-01C/manifest.json"), "utf8"));
|
||||
|
||||
test("M12-01C binds the desktop and Blender weak-reference sources", () => {
|
||||
assert.equal(manifest.nextTask, "M12-01D");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const actual = crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, artifact.path))).digest("hex");
|
||||
assert.equal(actual, artifact.sha256, `${artifact.path} hash drifted`);
|
||||
}
|
||||
const weakReferenceSource = fs.readFileSync(path.join(repoRoot, manifest.artifacts.blenderWeakReferenceSource.path), "utf8");
|
||||
assert.match(weakReferenceSource, /AssetWeakReference AssetRepresentation::make_weak_reference\(\) const/);
|
||||
assert.match(weakReferenceSource, /library_relative_identifier\(\) const/);
|
||||
});
|
||||
|
||||
test("M12-01C converts the desktop catalog baseline into schema v2", async () => {
|
||||
const generated = await catalog.createAssetCatalogManifestV2FromDesktop(desktop, 1);
|
||||
assert.deepEqual(generated, golden);
|
||||
assert.deepEqual(generated.catalogs.map((item) => item.path), ["Characters", "Characters/Heroes", "Materials/Metal"]);
|
||||
assert.deepEqual(generated.assets.map((item) => item.relativeAssetIdentifier), [
|
||||
"Material/M12 Brushed Metal", "Object/M12 Hero", "World/M12 Uncataloged World",
|
||||
]);
|
||||
assert.equal(generated.assets[2].catalogId, null);
|
||||
assert.equal(generated.assets[0].author, "");
|
||||
assert.equal(generated.assets[0].sourceSha256, null);
|
||||
assert.deepEqual(generated.libraries, []);
|
||||
assert.equal(generated.assets[1].customProperties.find((item) => item.name === "dimensions").type, "FLOAT_ARRAY");
|
||||
});
|
||||
|
||||
test("M12-01C stable IDs bind Blender weak-reference identity", async () => {
|
||||
const local = await catalog.createAssetCatalogV2StableId({ assetLibraryIdentifier: null, relativeAssetIdentifier: "Object/M12 Hero" });
|
||||
const external = await catalog.createAssetCatalogV2StableId({ assetLibraryIdentifier: "Studio", relativeAssetIdentifier: "hero.blend/Object/M12 Hero" });
|
||||
assert.match(local, /^asset:[a-f0-9]{64}$/);
|
||||
assert.match(external, /^asset:[a-f0-9]{64}$/);
|
||||
assert.notEqual(local, external);
|
||||
assert.equal(local, golden.assets[1].assetId);
|
||||
assert.equal(local, await catalog.createAssetCatalogV2StableId({ assetLibraryIdentifier: null, relativeAssetIdentifier: "Object/M12 Hero" }));
|
||||
});
|
||||
|
||||
test("M12-01C exposes UTF-8 byte and collection budgets", async () => {
|
||||
assert.equal(catalog.ASSET_CATALOG_SCHEMA, 2);
|
||||
assert.equal(catalog.ASSET_CATALOG_V2_BUDGET.maxCatalogSimpleNameBytes, 63);
|
||||
assert.equal(catalog.ASSET_CATALOG_V2_BUDGET.maxTagBytes, 63);
|
||||
assert.equal(catalog.ASSET_CATALOG_V2_BUDGET.maxCatalogs, 10_000);
|
||||
assert.equal(catalog.ASSET_CATALOG_V2_BUDGET.maxAssets, 100_000);
|
||||
assert.deepEqual(catalog.ASSET_CATALOG_V2_ID_TYPES, ["ACTION", "COLLECTION", "IMAGE", "MATERIAL", "NODE_GROUP", "OBJECT", "WORLD"]);
|
||||
assert.deepEqual(await catalog.parseAssetCatalogManifestV2(golden), golden);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
102
web/tests/unit/asset-preview-decode.test.mjs
Normal file
102
web/tests/unit/asset-preview-decode.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-preview-decode-unit-"));
|
||||
const transpile = (sourceName, outputName, replacements = []) => {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText));
|
||||
};
|
||||
transpile("asset-preview.ts", "asset-preview.mjs");
|
||||
transpile("asset-preview-decode.ts", "asset-preview-decode.mjs", [['from "./asset-preview"', 'from "./asset-preview.mjs"']]);
|
||||
const identityProtocol = await import(pathToFileURL(path.join(temporary, "asset-preview.mjs")));
|
||||
const decode = await import(pathToFileURL(path.join(temporary, "asset-preview-decode.mjs")));
|
||||
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const content = fs.readFileSync(path.join(root, "tests/golden/M12-02B/preview.png"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02C/manifest.json"), "utf8"));
|
||||
const buffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
async function identityFor(bytes, changes = {}) {
|
||||
const base = {
|
||||
...identity,
|
||||
content: {
|
||||
...identity.content,
|
||||
byteLength: bytes.byteLength,
|
||||
sha256: sha256(bytes),
|
||||
...changes,
|
||||
},
|
||||
};
|
||||
delete base.identitySha256;
|
||||
return identityProtocol.createAssetPreviewIdentity(base);
|
||||
}
|
||||
|
||||
test("M12-02C plans the checked-in PNG before decode", async () => {
|
||||
assert.equal(manifest.task, "M12-02C");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02D");
|
||||
assert.equal(manifest.negativeCaseCount, 8);
|
||||
assert.deepEqual(decode.ASSET_PREVIEW_DECODE_BUDGET, manifest.budget);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
assert.deepEqual(await decode.planAssetPreviewDecode(identity, buffer(content)), {
|
||||
schemaVersion: 1,
|
||||
identitySha256: identity.identitySha256,
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixelCount: 64,
|
||||
encodedByteLength: 513,
|
||||
decodedByteLength: 256,
|
||||
compressionRatio: 256 / 513,
|
||||
});
|
||||
});
|
||||
|
||||
test("M12-02C rejects byte, hash, MIME, dimension, pixel, and compression budgets before decode", async () => {
|
||||
const cases = [];
|
||||
const changedHash = buffer(content); new Uint8Array(changedHash)[changedHash.byteLength - 1] ^= 1;
|
||||
cases.push([identity, changedHash, "ASSET_SOURCE_HASH_MISMATCH"]);
|
||||
cases.push([{ ...identity, content: { ...identity.content, byteLength: 512 } }, buffer(content), "ASSET_PREVIEW_IDENTITY_MISMATCH"]);
|
||||
cases.push([await identityFor(content, { mimeType: "image/webp" }), buffer(content), "ASSET_MANIFEST_INVALID"]);
|
||||
|
||||
const wrongDimensions = Buffer.from(content);
|
||||
wrongDimensions.writeUInt32BE(9, 16);
|
||||
cases.push([await identityFor(wrongDimensions), buffer(wrongDimensions), "ASSET_MANIFEST_INVALID"]);
|
||||
|
||||
const hugeDimensions = Buffer.from(content);
|
||||
hugeDimensions.writeUInt32BE(4097, 16);
|
||||
hugeDimensions.writeUInt32BE(4097, 20);
|
||||
cases.push([await identityFor(hugeDimensions, { width: 4097, height: 4097 }), buffer(hugeDimensions), "ASSET_BUDGET_EXCEEDED"]);
|
||||
|
||||
const ratioDimensions = Buffer.from(content);
|
||||
ratioDimensions.writeUInt32BE(4096, 16);
|
||||
ratioDimensions.writeUInt32BE(4096, 20);
|
||||
cases.push([await identityFor(ratioDimensions, { width: 4096, height: 4096 }), buffer(ratioDimensions), "ASSET_BUDGET_EXCEEDED"]);
|
||||
|
||||
const oversized = new Uint8Array(decode.ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes + 1);
|
||||
cases.push([await identityFor(oversized, { width: 1, height: 1 }), oversized.buffer, "ASSET_BUDGET_EXCEEDED"]);
|
||||
|
||||
const corrupt = Buffer.from(content); corrupt[0] = 0;
|
||||
cases.push([await identityFor(corrupt), buffer(corrupt), "ASSET_MANIFEST_INVALID"]);
|
||||
|
||||
for (const [manifest, bytes, code] of cases) {
|
||||
await assert.rejects(decode.planAssetPreviewDecode(manifest, bytes), { code });
|
||||
assert.equal((await decode.planAssetPreviewDecode(identity, buffer(content))).identitySha256, identity.identitySha256);
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
25
web/tests/unit/asset-preview-dedup.test.mjs
Normal file
25
web/tests/unit/asset-preview-dedup.test.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02E/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-02E binds payload deduplication and metadata-isolation evidence", () => {
|
||||
assert.equal(manifest.task, "M12-02E");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02F");
|
||||
assert.deepEqual(manifest.claims, [
|
||||
"ONE_CONTENT_HASH_ONE_OPFS_PAYLOAD",
|
||||
"DISTINCT_ASSET_IDENTITY_PRESERVED",
|
||||
"DISTINCT_ASSET_METADATA_PRESERVED",
|
||||
"REOPENED_HEAD_VERIFIED",
|
||||
]);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
21
web/tests/unit/asset-preview-display.test.mjs
Normal file
21
web/tests/unit/asset-preview-display.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/manifest.json"), "utf8"));
|
||||
const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/desktop-report.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-02H binds desktop, main-thread, Offscreen, and metric evidence", () => {
|
||||
assert.equal(manifest.task, "M12-02H");
|
||||
assert.equal(manifest.enablingTask, false);
|
||||
assert.equal(manifest.parityStateChange, true);
|
||||
assert.equal(manifest.implementationClass, "LOCAL_EXACT");
|
||||
assert.equal(manifest.nextTask, "M12-03A");
|
||||
assert.deepEqual(manifest.backends, ["BLENDER_5_2_DESKTOP", "MAIN_THREAD_CANVAS_2D", "OFFSCREEN_CANVAS_2D"]);
|
||||
assert.equal(desktop.rgbaSha256, manifest.reference.rgbaSha256);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
70
web/tests/unit/asset-preview-identity.test.mjs
Normal file
70
web/tests/unit/asset-preview-identity.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-preview-identity-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/asset-preview.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "asset-preview.mjs"), transpiled.outputText);
|
||||
const preview = await import(pathToFileURL(path.join(temporary, "asset-preview.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/manifest.json"), "utf8"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const sourceBytes = fs.readFileSync(path.join(root, manifest.artifacts.source.path));
|
||||
const contentBytes = fs.readFileSync(path.join(root, manifest.artifacts.content.path));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const stableJSON = (value) => Array.isArray(value)
|
||||
? `[${value.map(stableJSON).join(",")}]`
|
||||
: value && typeof value === "object"
|
||||
? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
|
||||
test("M12-02B binds source, content, generator, protocol, and settings artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-02B");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02C");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
assert.equal(sha256(stableJSON(manifest.generatorSettings)), manifest.generatorSettingsSha256);
|
||||
assert.equal(manifest.generatorSettingsSha256, golden.generator.settingsSha256);
|
||||
});
|
||||
|
||||
test("M12-02B creates and parses the canonical preview identity", async () => {
|
||||
const { identitySha256, ...base } = golden;
|
||||
assert.deepEqual(await preview.createAssetPreviewIdentity(base), golden);
|
||||
assert.deepEqual(await preview.parseAssetPreviewIdentity(golden), golden);
|
||||
assert.equal(identitySha256, await preview.computeAssetPreviewIdentity(base));
|
||||
assert.notEqual(golden.source.sha256, golden.content.sha256);
|
||||
assert.deepEqual(await preview.verifyAssetPreviewIdentity(
|
||||
golden,
|
||||
sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength),
|
||||
contentBytes.buffer.slice(contentBytes.byteOffset, contentBytes.byteOffset + contentBytes.byteLength),
|
||||
golden.generator,
|
||||
), golden);
|
||||
});
|
||||
|
||||
test("M12-02B rejects independent source, content, generator, dimensions, and unknown-field drift", async () => {
|
||||
const source = sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength);
|
||||
const content = contentBytes.buffer.slice(contentBytes.byteOffset, contentBytes.byteOffset + contentBytes.byteLength);
|
||||
const changedSource = source.slice(0); new Uint8Array(changedSource)[0] ^= 1;
|
||||
const changedContent = content.slice(0); new Uint8Array(changedContent)[0] ^= 1;
|
||||
await assert.rejects(preview.verifyAssetPreviewIdentity(golden, changedSource, content, golden.generator), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(preview.verifyAssetPreviewIdentity(golden, source, changedContent, golden.generator), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(preview.verifyAssetPreviewIdentity(golden, source, content, { ...golden.generator, settingsSha256: "f".repeat(64) }), { code: "ASSET_PREVIEW_IDENTITY_MISMATCH" });
|
||||
await assert.rejects(preview.parseAssetPreviewIdentity({ ...golden, content: { ...golden.content, width: 9 } }), { code: "ASSET_PREVIEW_IDENTITY_MISMATCH" });
|
||||
await assert.rejects(preview.parseAssetPreviewIdentity({ ...golden, future: true }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
21
web/tests/unit/asset-preview-opfs-commit.test.mjs
Normal file
21
web/tests/unit/asset-preview-opfs-commit.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02D/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-02D binds OPFS-before-catalog ordering and browser evidence", () => {
|
||||
assert.equal(manifest.task, "M12-02D");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02E");
|
||||
assert.deepEqual(manifest.commitOrder, ["PRE_DECODE_GATE", "OPFS_WRITE", "OPFS_READBACK", "CATALOG_TRANSACTION"]);
|
||||
assert.deepEqual(manifest.faultPoints, ["BEFORE_OPFS_WRITE", "AFTER_OPFS_WRITE", "AFTER_CATALOG_PUT"]);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
21
web/tests/unit/asset-preview-quarantine.test.mjs
Normal file
21
web/tests/unit/asset-preview-quarantine.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02F/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-02F binds corrupt-payload quarantine and readable metadata evidence", () => {
|
||||
assert.equal(manifest.task, "M12-02F");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02G");
|
||||
assert.deepEqual(manifest.states, ["READY", "QUARANTINED", "REOPENED_QUARANTINED"]);
|
||||
assert.equal(manifest.failureCode, "ASSET_SOURCE_HASH_MISMATCH");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
21
web/tests/unit/asset-preview-reference-gc.test.mjs
Normal file
21
web/tests/unit/asset-preview-reference-gc.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02G/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-02G binds final-reference and cross-project reclamation evidence", () => {
|
||||
assert.equal(manifest.task, "M12-02G");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-02H");
|
||||
assert.deepEqual(manifest.sequence, ["REMOVE_FIRST_REFERENCE", "RETAIN_PAYLOAD", "REMOVE_FINAL_REFERENCE", "COLLECT_PAYLOAD"]);
|
||||
assert.equal(manifest.otherProjectPayload, "PRESERVED_READY");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
111
web/tests/unit/library-operation-identity.test.mjs
Normal file
111
web/tests/unit/library-operation-identity.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-operation-identity-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-operation-identity.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-operation-identity.mjs"), transpiled.outputText);
|
||||
const identity = await import(pathToFileURL(path.join(temporary, "library-operation-identity.mjs")));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03B/library-operation-bindings.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03B/manifest.json"), "utf8"));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
const bindingInput = (binding) => {
|
||||
const { invalidationToken, ...input } = structuredClone(binding);
|
||||
return input;
|
||||
};
|
||||
const stateFor = (binding) => ({
|
||||
sourceLibraryId: binding.source.sourceLibraryId,
|
||||
sourceSha256: binding.source.sourceSha256,
|
||||
sourceGeneration: binding.sourceGeneration,
|
||||
sourceRevision: binding.sourceRevision,
|
||||
dependencyClosureSha256: binding.dependencyClosureSha256,
|
||||
});
|
||||
|
||||
test("M12-03B binds the inventory, protocol, golden, and unit artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03B");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-03C");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-03B derives a stable source library ID from locator and bytes", async () => {
|
||||
const source = await identity.createLibrarySourceIdentity({
|
||||
sourceLocator: golden.source.sourceLocator,
|
||||
sourceSha256: golden.source.sourceSha256,
|
||||
});
|
||||
assert.deepEqual(source, golden.source);
|
||||
assert.deepEqual(await identity.parseLibrarySourceIdentity(source), source);
|
||||
assert.equal(source.sourceLibraryId, await identity.computeLibrarySourceId(source));
|
||||
assert.notEqual(source.sourceLibraryId, (await identity.createLibrarySourceIdentity({
|
||||
sourceLocator: `${source.sourceLocator}.moved`,
|
||||
sourceSha256: source.sourceSha256,
|
||||
})).sourceLibraryId);
|
||||
});
|
||||
|
||||
test("M12-03B fixes operation-specific owner and read-only semantics", async () => {
|
||||
const expected = [
|
||||
["APPEND", "LOCAL_MAIN", false, false],
|
||||
["LINK", "SOURCE_LIBRARY", true, true],
|
||||
["LIBRARY_OVERRIDE", "LOCAL_OVERRIDE", false, true],
|
||||
];
|
||||
for (let index = 0; index < golden.bindings.length; index++) {
|
||||
const binding = golden.bindings[index];
|
||||
assert.deepEqual(await identity.createLibraryOperationBinding(bindingInput(binding)), binding);
|
||||
assert.deepEqual(await identity.parseLibraryOperationBinding(binding), binding);
|
||||
assert.deepEqual(await identity.assertLibraryOperationBindingCurrent(binding, stateFor(binding)), binding);
|
||||
assert.deepEqual([binding.operation, binding.owner.kind, binding.readOnly, binding.referenceReadOnly], expected[index]);
|
||||
}
|
||||
assert.equal(new Set(golden.bindings.map((binding) => binding.invalidationToken)).size, 3);
|
||||
});
|
||||
|
||||
test("M12-03B rejects owner, read-only, source, and token substitution", async () => {
|
||||
const [append, link, override] = golden.bindings;
|
||||
await assert.rejects(identity.createLibraryOperationBinding({ ...bindingInput(append), readOnly: true }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
await assert.rejects(identity.createLibraryOperationBinding({ ...bindingInput(link), owner: append.owner }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
await assert.rejects(identity.createLibraryOperationBinding({ ...bindingInput(override), referenceReadOnly: false }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
await assert.rejects(identity.createLibraryOperationBinding({
|
||||
...bindingInput(override),
|
||||
owner: { ...override.owner, referenceSourceDataBlockId: "Object/Other" },
|
||||
}), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(identity.parseLibrarySourceIdentity({ ...golden.source, sourceSha256: "f".repeat(64) }), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(identity.parseLibraryOperationBinding({ ...append, sourceRevision: 8 }), { code: "REVISION_CONFLICT" });
|
||||
await assert.rejects(identity.parseLibraryOperationBinding({ ...link, owner: { ...link.owner, sourceDataBlockId: "Object/Other" } }), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(identity.parseLibraryOperationBinding({ ...override, future: true }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
});
|
||||
|
||||
test("M12-03B invalidates generation, revision, source, and dependency closure drift", async () => {
|
||||
const binding = golden.bindings[2];
|
||||
for (const drift of [
|
||||
{ sourceGeneration: 4 },
|
||||
{ sourceRevision: 8 },
|
||||
{ sourceSha256: "c".repeat(64) },
|
||||
{ dependencyClosureSha256: "d".repeat(64) },
|
||||
{ sourceLibraryId: `library:${"e".repeat(64)}` },
|
||||
]) {
|
||||
await assert.rejects(identity.assertLibraryOperationBindingCurrent(binding, { ...stateFor(binding), ...drift }), { code: "REVISION_CONFLICT" });
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-03B enforces exact schemas and UTF-8 budgets", async () => {
|
||||
await assert.rejects(identity.parseLibrarySourceIdentity({ schemaVersion: 2 }), { code: "PROTOCOL_MISMATCH" });
|
||||
await assert.rejects(identity.createLibrarySourceIdentity({ sourceLocator: "x".repeat(4_097), sourceSha256: "a".repeat(64) }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
await assert.rejects(identity.createLibraryOperationBinding({ ...bindingInput(golden.bindings[0]), sourceGeneration: 0 }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
81
web/tests/unit/render-compositor-media-recovery.test.mjs
Normal file
81
web/tests/unit/render-compositor-media-recovery.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/render-compositor-media-recovery.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const recovery = await import(moduleUrl);
|
||||
|
||||
const hash = "a".repeat(64);
|
||||
const codes = {
|
||||
RENDER: ["OPEN_CANCELLED", "GPU_TEXTURE_BUDGET_EXCEEDED"],
|
||||
COMPOSITOR: ["COMPOSITOR_CANCELLED", "COMPOSITOR_BUDGET_EXCEEDED"],
|
||||
MEDIA: ["SEQUENCER_CANCELLED", "SEQUENCER_BUDGET_EXCEEDED"],
|
||||
};
|
||||
const report = (domain) => ({
|
||||
schemaVersion: 1,
|
||||
domain,
|
||||
source: { byteLength: 64, sha256: hash },
|
||||
cancellation: { status: "CANCELLED", code: codes[domain][0], publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore: hash,
|
||||
identityAfter: hash,
|
||||
outputSha256Before: hash,
|
||||
outputSha256After: hash,
|
||||
},
|
||||
budget: { status: "BLOCKED", code: codes[domain][1], retainedIdentityHash: hash, temporaryResourcesAfter: 0 },
|
||||
release: { status: "RELEASED", releasedBytes: 64, releasedResources: 1, resourcesAfter: 0 },
|
||||
recovery: { status: "RECOVERED", identityHash: hash, outputSha256: hash, outputBytes: 64, visibleUnits: 4 },
|
||||
});
|
||||
|
||||
test("M11-14 accepts the complete render, compositor and media recovery suite", () => {
|
||||
const parsed = recovery.parseRenderCompositorMediaRecoverySuite([
|
||||
report("MEDIA"),
|
||||
report("RENDER"),
|
||||
report("COMPOSITOR"),
|
||||
]);
|
||||
assert.deepEqual(parsed.map((item) => item.domain), ["RENDER", "COMPOSITOR", "MEDIA"]);
|
||||
assert.equal(parsed.every((item) => item.release.resourcesAfter === 0), true);
|
||||
});
|
||||
|
||||
test("M11-14 rejects cancellation publication, domain code drift and duplicate domains", () => {
|
||||
const published = report("RENDER");
|
||||
published.cancellation.publishedResults = 1;
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoveryEvidence(published), /must be zero/);
|
||||
|
||||
const wrongCode = report("MEDIA");
|
||||
wrongCode.cancellation.code = "COMPOSITOR_CANCELLED";
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoveryEvidence(wrongCode), /cancellation contract/);
|
||||
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoverySuite([
|
||||
report("RENDER"),
|
||||
report("RENDER"),
|
||||
report("MEDIA"),
|
||||
]), /duplicate domains/);
|
||||
});
|
||||
|
||||
test("M11-14 rejects restart drift, budget mutation and incomplete release", () => {
|
||||
const restartDrift = report("COMPOSITOR");
|
||||
restartDrift.restart.outputSha256After = "b".repeat(64);
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoveryEvidence(restartDrift), /restart changed/);
|
||||
|
||||
const budgetMutation = report("RENDER");
|
||||
budgetMutation.budget.retainedIdentityHash = "b".repeat(64);
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoveryEvidence(budgetMutation), /budget failure changed/);
|
||||
|
||||
const leaked = report("MEDIA");
|
||||
leaked.release.resourcesAfter = 1;
|
||||
assert.throws(() => recovery.parseRenderCompositorMediaRecoveryEvidence(leaked), /must be zero/);
|
||||
});
|
||||
Reference in New Issue
Block a user