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 { if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${label} must be an object`); return value as Record; } function exact(value: Record, fields: ReadonlySet, 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}`; }