Files
workinf_Blender_Wasm/web/protocol/asset-preview-decode.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

132 lines
5.9 KiB
TypeScript

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