Files
workinf_Blender_Wasm/web/protocol/asset-preview.ts
mes123456 5a11045ca5
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance Blender 5.2 web parity through M12-03D
2026-08-17 17:30:27 -04:00

203 lines
8.5 KiB
TypeScript

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;
}