Files
workinf_Blender_Wasm/web/protocol/compositor.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

572 lines
29 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,
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<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 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<ArrayBuffer>();
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<string, CompositorFrameCacheEntry>();
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<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`);
}
}
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<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 };
}
/** 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<string, CompositorLinkIR[]>();
for (const link of graph.links) {
const links = incoming.get(link.toNodeId) ?? [];
links.push(link);
incoming.set(link.toNodeId, links);
}
const visited = new Set<string>();
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<string>): 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<string, CompositorImageBuffer>,
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<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[] = [];
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<string> {
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<string, CompositorImageBuffer>,
frame: number,
width?: number,
height?: number,
): Promise<string> {
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<string, CompositorImageBuffer>,
cache: CompositorFrameCache,
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
): Promise<CompositorCachedExecutionResult> {
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 };
}