Files
workinf_Blender_Wasm/web/protocol/compositor.ts
2026-08-12 04:47:48 -04:00

369 lines
19 KiB
TypeScript

import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const COMPOSITOR_SCHEMA = 1 as const;
export const COMPOSITOR_BUDGET = {
maxNodes: 4_096,
maxLinks: 16_384,
maxResources: 4_096,
maxDimension: 8_192,
maxPixels: 16_777_216,
maxImageBytes: 256 * 1024 * 1024,
maxBlurRadius: 32,
maxOperations: 100_000_000,
} as const;
export const COMPOSITOR_NODE_TYPES = [
"IMAGE",
"RENDER_LAYER",
"CONSTANT_COLOR",
"TRANSFORM",
"INVERT",
"EXPOSURE",
"ALPHA_OVER",
"BLUR",
"MIX",
"VIEWER",
"COMPOSITE",
"UNSUPPORTED",
] as const;
export type CompositorNodeType = typeof COMPOSITOR_NODE_TYPES[number];
export interface CompositorResourceIR {
id: string;
kind: "IMAGE" | "RENDER_LAYER";
sourceId: string;
width?: number;
height?: number;
sha256?: string;
}
export interface CompositorNodeIR {
id: string;
type: CompositorNodeType;
name: string;
blenderType?: string;
properties: Record<string, unknown>;
}
export interface CompositorLinkIR {
fromNodeId: string;
fromSocket: string;
toNodeId: string;
toSocket: string;
}
export interface CompositorGraphIR {
schemaVersion: typeof COMPOSITOR_SCHEMA;
id: string;
name: string;
outputNodeId: string;
nodes: CompositorNodeIR[];
links: CompositorLinkIR[];
resources: CompositorResourceIR[];
}
export interface CompositorImageBuffer {
width: number;
height: number;
data: Float32Array;
colorSpace: "LINEAR_SRGB";
}
export interface CompositorExecutionResult {
composite: CompositorImageBuffer;
viewers: Map<string, CompositorImageBuffer>;
evaluatedNodeIds: string[];
}
export class CompositorValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "CompositorValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const SUPPORTED = new Set<CompositorNodeType>(COMPOSITOR_NODE_TYPES.filter((type) => type !== "UNSUPPORTED"));
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} is invalid`);
}
return value;
}
function finite(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} is outside the bounded range`);
}
return value;
}
function properties(value: unknown, index: number): Record<string, unknown> {
if (!record(value)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].properties must be an object`);
let encoded: string;
try { encoded = JSON.stringify(value); }
catch { throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].properties is not serializable`); }
if (new TextEncoder().encode(encoded).byteLength > 64 * 1024) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `nodes[${index}].properties exceeds 64 KiB`);
}
return value;
}
function validateNodeProperties(node: CompositorNodeIR, index: number): void {
const value = node.properties;
const allowed = (names: string[]): void => {
if (Object.keys(value).some((key) => !names.includes(key))) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] contains unsupported properties`);
}
};
if (node.type === "IMAGE" || node.type === "RENDER_LAYER") {
allowed(["resourceId"]);
text(value.resourceId, `nodes[${index}].properties.resourceId`);
}
else if (node.type === "CONSTANT_COLOR") {
allowed(["color"]);
if (!Array.isArray(value.color) || value.color.length !== 4) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].color must contain RGBA`);
value.color.forEach((channel, channelIndex) => finite(channel, `nodes[${index}].color[${channelIndex}]`, -65504, 65504));
}
else if (node.type === "TRANSFORM") {
allowed(["translateX", "translateY", "scaleX", "scaleY"]);
for (const key of ["translateX", "translateY"] as const) if (value[key] !== undefined) finite(value[key], `nodes[${index}].${key}`, -1_000_000, 1_000_000);
for (const key of ["scaleX", "scaleY"] as const) if (value[key] !== undefined) finite(value[key], `nodes[${index}].${key}`, 0.0001, 10_000);
}
else if (node.type === "EXPOSURE") {
allowed(["exposure"]);
finite(value.exposure ?? 0, `nodes[${index}].exposure`, -20, 20);
}
else if (node.type === "BLUR") {
allowed(["radius"]);
const radius = finite(value.radius ?? 0, `nodes[${index}].radius`, 0, COMPOSITOR_BUDGET.maxBlurRadius);
if (!Number.isSafeInteger(radius)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].radius must be an integer`);
}
else if (node.type === "MIX") {
allowed(["factor"]);
finite(value.factor ?? 0.5, `nodes[${index}].factor`, 0, 1);
}
else if (["INVERT", "ALPHA_OVER", "VIEWER", "COMPOSITE"].includes(node.type)) allowed([]);
else if (node.type === "UNSUPPORTED") {
if (!node.blenderType) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] must preserve blenderType`);
}
}
export function parseCompositorGraph(value: unknown): CompositorGraphIR {
if (!record(value) || value.schemaVersion !== COMPOSITOR_SCHEMA || !Array.isArray(value.nodes) ||
!Array.isArray(value.links) || !Array.isArray(value.resources)) {
throw new CompositorValidationError("PROTOCOL_MISMATCH", "Unsupported Compositor graph schema");
}
if (value.nodes.length === 0 || value.nodes.length > COMPOSITOR_BUDGET.maxNodes ||
value.links.length > COMPOSITOR_BUDGET.maxLinks || value.resources.length > COMPOSITOR_BUDGET.maxResources) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor graph exceeds the node, link or resource budget");
}
const nodeIds = new Set<string>();
const nodes = value.nodes.map((item, index): CompositorNodeIR => {
if (!record(item) || !COMPOSITOR_NODE_TYPES.includes(item.type as CompositorNodeType)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] is invalid`);
const id = text(item.id, `nodes[${index}].id`);
if (nodeIds.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Duplicate node ${id}`);
nodeIds.add(id);
const node = { id, type: item.type as CompositorNodeType, name: text(item.name, `nodes[${index}].name`), properties: properties(item.properties ?? {}, index), blenderType: item.blenderType === undefined ? undefined : text(item.blenderType, `nodes[${index}].blenderType`) };
validateNodeProperties(node, index);
return node;
});
const resourceIds = new Set<string>();
const resources = value.resources.map((item, index): CompositorResourceIR => {
if (!record(item) || !["IMAGE", "RENDER_LAYER"].includes(item.kind as string)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}] is invalid`);
const id = text(item.id, `resources[${index}].id`);
if (resourceIds.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Duplicate resource ${id}`);
resourceIds.add(id);
const width = item.width === undefined ? undefined : finite(item.width, `resources[${index}].width`, 1, COMPOSITOR_BUDGET.maxDimension);
const height = item.height === undefined ? undefined : finite(item.height, `resources[${index}].height`, 1, COMPOSITOR_BUDGET.maxDimension);
if ((width !== undefined && !Number.isSafeInteger(width)) || (height !== undefined && !Number.isSafeInteger(height))) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}] dimensions must be integers`);
if (item.sha256 !== undefined && (typeof item.sha256 !== "string" || !SHA256.test(item.sha256))) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}].sha256 is invalid`);
return { id, kind: item.kind as CompositorResourceIR["kind"], sourceId: text(item.sourceId, `resources[${index}].sourceId`), width, height, sha256: item.sha256 as string | undefined };
});
const destinations = new Set<string>();
const links = value.links.map((item, index): CompositorLinkIR => {
if (!record(item)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `links[${index}] is invalid`);
const link = { fromNodeId: text(item.fromNodeId, `links[${index}].fromNodeId`), fromSocket: text(item.fromSocket, `links[${index}].fromSocket`), toNodeId: text(item.toNodeId, `links[${index}].toNodeId`), toSocket: text(item.toSocket, `links[${index}].toSocket`) };
if (!nodeIds.has(link.fromNodeId) || !nodeIds.has(link.toNodeId)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `links[${index}] references a missing node`);
const destination = `${link.toNodeId}:${link.toSocket}`;
if (destinations.has(destination)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Multiple links target ${destination}`);
destinations.add(destination);
return link;
});
const outputNodeId = text(value.outputNodeId, "outputNodeId");
if (!nodeIds.has(outputNodeId) || nodes.find((node) => node.id === outputNodeId)?.type !== "COMPOSITE") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "outputNodeId must reference a COMPOSITE node");
const outgoing = new Map(nodes.map((node) => [node.id, [] as string[]]));
for (const link of links) outgoing.get(link.fromNodeId)?.push(link.toNodeId);
const active = new Set<string>();
const done = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_CYCLE", `Compositor graph cycle includes ${id}`);
if (done.has(id)) return;
active.add(id);
for (const next of outgoing.get(id) ?? []) visit(next);
active.delete(id);
done.add(id);
};
nodes.forEach((node) => visit(node.id));
return { schemaVersion: COMPOSITOR_SCHEMA, id: text(value.id, "id"), name: text(value.name, "name"), outputNodeId, nodes, links, resources };
}
function validateImage(image: CompositorImageBuffer, name: string): CompositorImageBuffer {
const pixels = image.width * image.height;
if (!Number.isSafeInteger(image.width) || !Number.isSafeInteger(image.height) || image.width < 1 || image.height < 1 ||
image.width > COMPOSITOR_BUDGET.maxDimension || image.height > COMPOSITOR_BUDGET.maxDimension || pixels > COMPOSITOR_BUDGET.maxPixels ||
image.data.length !== pixels * 4 || image.data.byteLength > COMPOSITOR_BUDGET.maxImageBytes) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${name} exceeds the image budget`);
}
if (image.colorSpace !== "LINEAR_SRGB") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} must use LINEAR_SRGB`);
return image;
}
function allocate(width: number, height: number): CompositorImageBuffer {
const pixels = width * height;
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 ||
width > COMPOSITOR_BUDGET.maxDimension || height > COMPOSITOR_BUDGET.maxDimension ||
pixels > COMPOSITOR_BUDGET.maxPixels || pixels * 16 > COMPOSITOR_BUDGET.maxImageBytes)
{
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor output exceeds the image budget");
}
return validateImage({ width, height, data: new Float32Array(width * height * 4), colorSpace: "LINEAR_SRGB" }, "Compositor output");
}
export function gateCompositorGraph(value: unknown, availableResourceIds: ReadonlySet<string>): CapabilityGateResult {
try {
const graph = parseCompositorGraph(value);
const unsupported = graph.nodes.filter((node) => !SUPPORTED.has(node.type)).map((node) => node.blenderType ?? node.type);
if (unsupported.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_NODE_UNSUPPORTED", `Unsupported compositor nodes: ${unsupported.join(", ")}`)]);
const missing = graph.resources.filter((resource) => !availableResourceIds.has(resource.sourceId));
if (missing.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resources: ${missing.map((resource) => resource.sourceId).join(", ")}`)]);
return readyGate("N-020", "BOUNDED_CPU_COMPOSITOR");
}
catch (error) {
const code = error instanceof CompositorValidationError ? error.code : "COMPOSITOR_GRAPH_INVALID";
return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue(code, error instanceof Error ? error.message : "Invalid compositor graph")]);
}
}
export function executeCompositorGraph(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
options: { width?: number; height?: number; cancelled?: () => boolean } = {},
): CompositorExecutionResult {
const graph = parseCompositorGraph(value);
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
const incoming = new Map<string, CompositorLinkIR>();
graph.links.forEach((link) => incoming.set(`${link.toNodeId}:${link.toSocket}`, link));
const outputs = new Map<string, CompositorImageBuffer>();
const viewers = new Map<string, CompositorImageBuffer>();
const evaluatedNodeIds: string[] = [];
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
const image = source ? outputs.get(source) : undefined;
if (!image) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId}.${socket} is not connected to an evaluated image`);
return image;
};
const sameSize = (left: CompositorImageBuffer, right: CompositorImageBuffer, nodeId: string): void => {
if (left.width !== right.width || left.height !== right.height) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId} inputs have different dimensions`);
};
const evaluate = (id: string): CompositorImageBuffer => {
const existing = outputs.get(id);
if (existing) return existing;
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
const node = byId.get(id);
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
let output: CompositorImageBuffer;
if (node.type === "UNSUPPORTED") throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `${node.blenderType ?? node.name} is not executable locally`);
if (node.type === "IMAGE" || node.type === "RENDER_LAYER") {
const resourceId = node.properties.resourceId as string;
const resource = graph.resources.find((item) => item.id === resourceId);
const image = resource ? sourceImages.get(resource.sourceId) : undefined;
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource?.sourceId ?? resourceId}`);
output = validateImage(image, node.id);
}
else if (node.type === "CONSTANT_COLOR") {
const width = options.width ?? 1;
const height = options.height ?? 1;
output = allocate(width, height);
const color = node.properties.color as number[];
for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset);
}
else if (node.type === "TRANSFORM") {
const input = requireInput(id, "Image");
output = allocate(input.width, input.height);
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
}
}
else if (node.type === "INVERT" || node.type === "EXPOSURE") {
const input = requireInput(id, "Image");
output = allocate(input.width, input.height);
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
for (let offset = 0; offset < input.data.length; offset += 4) {
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
output.data[offset + 3] = input.data[offset + 3];
}
}
else if (node.type === "MIX" || node.type === "ALPHA_OVER") {
const left = requireInput(id, node.type === "MIX" ? "A" : "Background");
const right = requireInput(id, node.type === "MIX" ? "B" : "Foreground");
sameSize(left, right, id);
output = allocate(left.width, left.height);
for (let offset = 0; offset < left.data.length; offset += 4) {
if (node.type === "MIX") {
const factor = Number(node.properties.factor ?? 0.5);
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
}
else {
const backgroundAlpha = left.data[offset + 3], foregroundAlpha = right.data[offset + 3];
const alpha = foregroundAlpha + backgroundAlpha * (1 - foregroundAlpha);
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = alpha > 0 ? (right.data[offset + channel] * foregroundAlpha + left.data[offset + channel] * backgroundAlpha * (1 - foregroundAlpha)) / alpha : 0;
output.data[offset + 3] = alpha;
}
}
}
else if (node.type === "BLUR") {
const input = requireInput(id, "Image");
const radius = Number(node.properties.radius ?? 0);
const operations = input.width * input.height * (radius * 2 + 1) ** 2;
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
output = allocate(input.width, input.height);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
const target = (y * input.width + x) * 4;
let samples = 0;
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
const px = x + dx, py = y + dy;
if (px < 0 || py < 0 || px >= input.width || py >= input.height) continue;
const source = (py * input.width + px) * 4;
for (let channel = 0; channel < 4; channel++) output.data[target + channel] += input.data[source + channel];
samples++;
}
for (let channel = 0; channel < 4; channel++) output.data[target + channel] /= samples;
}
}
else {
output = requireInput(id, "Image");
if (node.type === "VIEWER") viewers.set(id, output);
}
outputs.set(id, output);
evaluatedNodeIds.push(id);
return output;
};
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
}