178 lines
7.3 KiB
TypeScript
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}`;
|
|
}
|