Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = {
|
||||
maxImageBytes: 256 * 1024 * 1024,
|
||||
maxBlurRadius: 32,
|
||||
maxOperations: 100_000_000,
|
||||
maxFrameCacheBytes: 256 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export const COMPOSITOR_NODE_TYPES = [
|
||||
@@ -77,6 +78,69 @@ export interface CompositorExecutionResult {
|
||||
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;
|
||||
|
||||
@@ -267,6 +331,11 @@ export function executeCompositorGraph(
|
||||
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;
|
||||
@@ -279,7 +348,7 @@ export function executeCompositorGraph(
|
||||
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");
|
||||
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);
|
||||
@@ -297,7 +366,7 @@ export function executeCompositorGraph(
|
||||
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);
|
||||
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");
|
||||
@@ -305,6 +374,7 @@ export function executeCompositorGraph(
|
||||
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);
|
||||
@@ -315,6 +385,7 @@ export function executeCompositorGraph(
|
||||
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];
|
||||
}
|
||||
@@ -325,6 +396,7 @@ export function executeCompositorGraph(
|
||||
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;
|
||||
@@ -344,6 +416,7 @@ export function executeCompositorGraph(
|
||||
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++) {
|
||||
@@ -366,3 +439,52 @@ export function executeCompositorGraph(
|
||||
};
|
||||
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> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user