Files
workinf_Blender_Wasm/web/protocol/texture-paint-asset.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

178 lines
7.3 KiB
TypeScript

import { parseUdimTilePatch, type UdimTilePatchIR } from "./paint";
export const TEXTURE_PAINT_ASSET_SCHEMA_VERSION = 1 as const;
export type TexturePaintTileKind = "PACKED" | "UDIM";
export type TexturePaintTileFault = "before-asset-write" | "after-asset-write" | "before-binding-commit";
export interface TexturePaintTileTargetIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
imageId: string;
textureAssetId: string;
kind: TexturePaintTileKind;
tile: number;
revision: number;
width: number;
height: number;
mimeType: "image/png";
colorSpace: "SRGB" | "LINEAR";
sourcePath: string;
baseAssetSha256: string;
}
export interface TexturePaintTileCommitIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
target: TexturePaintTileTargetIR;
patch: UdimTilePatchIR;
faultAt?: TexturePaintTileFault;
}
export interface TexturePaintTileBindingRequestIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
textureAssetId: string;
tile: number;
}
export interface TexturePaintTileBindingIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
imageId: string;
textureAssetId: string;
kind: TexturePaintTileKind;
tile: number;
revision: number;
generation: number;
width: number;
height: number;
mimeType: "image/png";
colorSpace: "SRGB" | "LINEAR";
sourcePath: string;
assetId: string;
assetSha256: string;
pixelSha256: string;
bytes: number;
path: string;
updatedAt: string;
}
export interface TexturePaintTileCommitResultIR {
projectId: string;
persisted: true;
binding: TexturePaintTileBindingIR;
previousAssetSha256: string;
orphanedAssetPossible: boolean;
}
export interface TexturePaintTileBindingResultIR {
projectId: string;
binding?: TexturePaintTileBindingIR;
}
const SHA256 = /^[a-f0-9]{64}$/;
const TARGET_FIELDS = new Set(["schemaVersion", "projectId", "imageId", "textureAssetId", "kind", "tile", "revision", "width", "height", "mimeType", "colorSpace", "sourcePath", "baseAssetSha256"]);
const COMMIT_FIELDS = new Set(["schemaVersion", "target", "patch", "faultAt"]);
const BINDING_REQUEST_FIELDS = new Set(["schemaVersion", "projectId", "textureAssetId", "tile"]);
const encoder = new TextEncoder();
function fail(message: string): never {
throw new Error(`PAINT_SCHEMA_INVALID: ${message}`);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${label} must be an object`);
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, label: string): void {
if (Object.keys(value).some((field) => !fields.has(field))) fail(`${label} contains undeclared fields`);
}
function boundedString(value: unknown, label: string, prefix?: string, maxBytes = 512): string {
if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) {
fail(`${label} is outside the bounded identity range`);
}
return value;
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail(`${label} must be a non-negative safe integer`);
return value;
}
function tileNumber(value: unknown): number {
const tile = integer(value, "tile");
if (tile < 1001 || tile > 1999) fail("tile must be in [1001,1999]");
return tile;
}
function digest(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256.test(value)) fail(`${label} must be a lowercase SHA-256 digest`);
return value;
}
export function validateTexturePaintTileTarget(value: unknown): TexturePaintTileTargetIR {
const target = record(value, "Texture paint target");
exact(target, TARGET_FIELDS, "Texture paint target");
if (target.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
if (target.kind !== "PACKED" && target.kind !== "UDIM") fail("Texture paint target kind is invalid");
if (target.mimeType !== "image/png") fail("Texture paint atomic tile currently requires packed PNG bytes");
if (target.colorSpace !== "SRGB" && target.colorSpace !== "LINEAR") fail("Texture paint color space is invalid");
const width = integer(target.width, "width");
const height = integer(target.height, "height");
if (width < 1 || height < 1 || width > 16_384 || height > 16_384 || width * height * 4 > 256 * 1024 * 1024) {
throw new Error("PAINT_BUDGET_EXCEEDED: Texture paint tile dimensions exceed the RGBA8 budget");
}
const sourcePath = boundedString(target.sourcePath, "sourcePath", undefined, 1024);
if (sourcePath.includes("\\") || sourcePath.split("/").includes("..")) fail("Texture paint source path escapes the project");
return {
schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION,
projectId: boundedString(target.projectId, "projectId", undefined, 128),
imageId: boundedString(target.imageId, "imageId", "image:", 256),
textureAssetId: boundedString(target.textureAssetId, "textureAssetId", undefined, 256),
kind: target.kind,
tile: tileNumber(target.tile),
revision: integer(target.revision, "revision"),
width,
height,
mimeType: "image/png",
colorSpace: target.colorSpace,
sourcePath,
baseAssetSha256: digest(target.baseAssetSha256, "baseAssetSha256"),
};
}
export function validateTexturePaintTileCommit(value: unknown): TexturePaintTileCommitIR {
const input = record(value, "Texture paint commit");
exact(input, COMMIT_FIELDS, "Texture paint commit");
if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
const target = validateTexturePaintTileTarget(input.target);
const patch = parseUdimTilePatch(input.patch);
if (patch.textureAssetId !== target.textureAssetId || patch.tile !== target.tile || patch.revision !== target.revision ||
patch.width !== target.width || patch.height !== target.height || patch.colorSpace !== target.colorSpace) {
fail("Texture paint patch does not match its packed tile target");
}
const faultAt = input.faultAt;
if (faultAt !== undefined && faultAt !== "before-asset-write" && faultAt !== "after-asset-write" && faultAt !== "before-binding-commit") {
fail("Texture paint fault injection point is invalid");
}
return { schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION, target, patch, faultAt };
}
export function validateTexturePaintTileBindingRequest(value: unknown): TexturePaintTileBindingRequestIR {
const input = record(value, "Texture paint binding request");
exact(input, BINDING_REQUEST_FIELDS, "Texture paint binding request");
if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
return {
schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION,
projectId: boundedString(input.projectId, "projectId", undefined, 128),
textureAssetId: boundedString(input.textureAssetId, "textureAssetId", undefined, 256),
tile: tileNumber(input.tile),
};
}
export function texturePaintTileBindingKey(request: TexturePaintTileBindingRequestIR): string {
return `texture-paint:v1:${request.projectId}:${encodeURIComponent(request.textureAssetId)}:${request.tile}`;
}