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, maxFrameCacheBytes: 256 * 1024 * 1024, } 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 const COMPOSITOR_WEBGPU_NODE_ALLOWLIST = [ "CONSTANT_COLOR", "EXPOSURE", "INVERT", "COMPOSITE", ] as const; export type CompositorWebGPUNodeType = typeof COMPOSITOR_WEBGPU_NODE_ALLOWLIST[number]; export type CompositorWebGPUInstructionIR = | { nodeId: string; type: "CONSTANT_COLOR"; color: readonly [number, number, number, number] } | { nodeId: string; type: "EXPOSURE"; exposure: number } | { nodeId: string; type: "INVERT" } | { nodeId: string; type: "COMPOSITE" }; export interface CompositorWebGPUPlanIR { schemaVersion: typeof COMPOSITOR_SCHEMA; graphId: string; outputNodeId: string; instructions: CompositorWebGPUInstructionIR[]; } 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; } 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; evaluatedNodeIds: string[]; } export interface CompositorCachedExecutionResult extends CompositorExecutionResult { cacheKey: string; cacheHit: boolean; } interface CompositorFrameCacheEntry { result: CompositorExecutionResult; byteLength: number; } function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer { return { ...image, data: image.data.slice() }; } function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult { return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] }; } function executionBytes(result: CompositorExecutionResult): number { const unique = new Set(); unique.add(result.composite.data.buffer as ArrayBuffer); for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer); return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0); } export class CompositorFrameCache { readonly maxBytes: number; private readonly entries = new Map(); private currentBytes = 0; constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) { if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid"); this.maxBytes = maxBytes; } get byteLength(): number { return this.currentBytes; } get size(): number { return this.entries.size; } get(key: string): CompositorExecutionResult | undefined { const entry = this.entries.get(key); if (!entry) return undefined; this.entries.delete(key); this.entries.set(key, entry); return cloneExecution(entry.result); } set(key: string, result: CompositorExecutionResult): void { const clone = cloneExecution(result); const byteLength = executionBytes(clone); if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget"); const previous = this.entries.get(key); if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); } while (this.currentBytes + byteLength > this.maxBytes) { const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined; if (!oldest) break; this.entries.delete(oldest[0]); this.currentBytes -= oldest[1].byteLength; } this.entries.set(key, { result: clone, byteLength }); this.currentBytes += byteLength; } } 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(COMPOSITOR_NODE_TYPES.filter((type) => type !== "UNSUPPORTED")); function record(value: unknown): value is Record { 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 { 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`); } } function unsupportedCompositorNodeNames(graph: CompositorGraphIR): string[] { return [...new Set(graph.nodes .filter((node) => !SUPPORTED.has(node.type)) .map((node) => node.blenderType ?? node.type))].sort(); } function assertCompositorGraphExecutable(graph: CompositorGraphIR): void { const unsupported = unsupportedCompositorNodeNames(graph); if (unsupported.length > 0) { throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `Compositor graph contains unsupported nodes: ${unsupported.join(", ")}`); } } 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(); 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(); 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(); 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(); const done = new Set(); 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 }; } /** Compiles only the node types with an independent CPU/WebGPU golden. */ export function compileCompositorWebGPUPlan(value: unknown): CompositorWebGPUPlanIR { const graph = parseCompositorGraph(value); if (graph.resources.length !== 0) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", "WebGPU compositor allowlist does not include resource inputs"); const unsupported = graph.nodes.filter((node) => !COMPOSITOR_WEBGPU_NODE_ALLOWLIST.includes(node.type as CompositorWebGPUNodeType)); if (unsupported.length > 0) { const names = [...new Set(unsupported.map((node) => node.blenderType ?? node.type))].sort(); throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor nodes are not allowlisted: ${names.join(", ")}`); } const byId = new Map(graph.nodes.map((node) => [node.id, node])); const incoming = new Map(); for (const link of graph.links) { const links = incoming.get(link.toNodeId) ?? []; links.push(link); incoming.set(link.toNodeId, links); } const visited = new Set(); const instructions: CompositorWebGPUInstructionIR[] = []; const visit = (nodeId: string): void => { if (visited.has(nodeId)) return; const node = byId.get(nodeId); if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing WebGPU compositor node ${nodeId}`); const links = incoming.get(nodeId) ?? []; if (node.type === "CONSTANT_COLOR") { if (links.length !== 0) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId} constant input must be disconnected`); const color = node.properties.color as number[]; instructions.push({ nodeId, type: "CONSTANT_COLOR", color: [color[0], color[1], color[2], color[3]] }); } else { if (links.length !== 1 || links[0].toSocket !== "Image") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId}.Image requires exactly one input`); visit(links[0].fromNodeId); if (node.type === "EXPOSURE") instructions.push({ nodeId, type: "EXPOSURE", exposure: Number(node.properties.exposure ?? 0) }); else if (node.type === "INVERT") instructions.push({ nodeId, type: "INVERT" }); else if (node.type === "COMPOSITE") instructions.push({ nodeId, type: "COMPOSITE" }); else throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `${node.type} has no WebGPU golden`); } visited.add(nodeId); }; visit(graph.outputNodeId); if (visited.size !== graph.nodes.length || graph.links.length !== graph.nodes.length - 1 || instructions[0]?.type !== "CONSTANT_COLOR") { throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor schema 1 requires one connected constant-to-composite chain"); } return { schemaVersion: COMPOSITOR_SCHEMA, graphId: graph.id, outputNodeId: graph.outputNodeId, instructions }; } 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): CapabilityGateResult { try { const graph = parseCompositorGraph(value); const unsupported = unsupportedCompositorNodeNames(graph); 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, options: { width?: number; height?: number; cancelled?: () => boolean } = {}, ): CompositorExecutionResult { const graph = parseCompositorGraph(value); assertCompositorGraphExecutable(graph); const byId = new Map(graph.nodes.map((node) => [node.id, node])); const incoming = new Map(); graph.links.forEach((link) => incoming.set(`${link.toNodeId}:${link.toSocket}`, link)); const outputs = new Map(); const viewers = new Map(); const evaluatedNodeIds: string[] = []; let operationCounter = 0; const checkCancelled = (operations = 1): void => { operationCounter += operations; if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); }; 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; checkCancelled(); 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) { checkCancelled(); 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++) { checkCancelled(); 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) { checkCancelled(); 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) { checkCancelled(); 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++) { checkCancelled((radius * 2 + 1) ** 2); 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 }; } async function sha256Bytes(data: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); } function stableJSON(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; return JSON.stringify(value); } export async function compositorFrameCacheKey( value: unknown, sourceImages: ReadonlyMap, frame: number, width?: number, height?: number, ): Promise { const graph = parseCompositorGraph(value); if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid"); const sources: Array<{ id: string; width: number; height: number; sha256: string }> = []; for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) { const image = sourceImages.get(resource.sourceId); if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`); validateImage(image, resource.sourceId); const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer); if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`); sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 }); } const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null })); return sha256Bytes(descriptor.buffer as ArrayBuffer); } export async function executeCompositorGraphCached( value: unknown, sourceImages: ReadonlyMap, cache: CompositorFrameCache, options: { frame: number; width?: number; height?: number; cancelled?: () => boolean }, ): Promise { assertCompositorGraphExecutable(parseCompositorGraph(value)); if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height); if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); const cached = cache.get(cacheKey); if (cached) return { ...cached, cacheKey, cacheHit: true }; const result = executeCompositorGraph(value, sourceImages, options); cache.set(cacheKey, result); return { ...result, cacheKey, cacheHit: false }; }