Files
workinf_Blender_Wasm/web/protocol/server-render-job.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

314 lines
17 KiB
TypeScript

import type { ErrorCode } from "./error";
/** Server render jobs are deliberately a separate contract from realtime routing. */
export const SERVER_RENDER_JOB_SCHEMA = 1 as const;
export const SERVER_RENDER_JOB_BUDGET = {
maxSourceBytes: 512 * 1024 * 1024,
maxOutputBytes: 512 * 1024 * 1024,
maxSettingsBytes: 256 * 1024,
maxSettingsNodes: 10_000,
maxJobIdBytes: 128,
maxBuildVersionBytes: 64,
} as const;
export const SERVER_RENDER_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
export type ServerRenderJobStatus = typeof SERVER_RENDER_JOB_STATUSES[number];
export interface BlenderBuildIdentityIR {
version: string;
buildSha256: string;
}
export interface ServerRenderSettingsIR {
renderEngine: "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH";
frameStart: number;
frameEnd: number;
resolutionX: number;
resolutionY: number;
resolutionPercentage: number;
samples: number;
outputMime: "image/png" | "image/openexr";
transparent: boolean;
}
export interface ServerRenderJobRequestIR {
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
jobId: string;
sourceBlendSha256: string;
sourceBlendByteLength: number;
sourceRevision: number;
blenderBuild: BlenderBuildIdentityIR;
settings: ServerRenderSettingsIR;
settingsSha256: string;
requestSha256: string;
}
export interface ServerRenderJobResultIR {
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
jobId: string;
status: ServerRenderJobStatus;
sourceBlendSha256: string;
sourceBlendByteLength: number;
sourceRevision: number;
blenderBuild: BlenderBuildIdentityIR;
settingsSha256: string;
outputMime?: ServerRenderSettingsIR["outputMime"];
outputSha256?: string;
outputByteLength?: number;
requestSha256: string;
resultSha256: string;
errorCode?: ErrorCode;
}
export class ServerRenderJobValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "ServerRenderJobValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
const BUILD_VERSION = /^5\.2\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/;
const OUTPUT_MIMES = ["image/png", "image/openexr"] as const;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string, code: ErrorCode = "SERVER_RENDER_REQUEST_INVALID"): void {
const allowedSet = new Set(allowed);
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
throw new ServerRenderJobValidationError(code, `${name} contains undeclared fields`);
}
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new ServerRenderJobValidationError("SERVER_RENDER_HASH_INVALID", `${name} must be a lowercase SHA-256 digest`);
}
return value;
}
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `${name} is outside the render budget`);
}
return value;
}
function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string {
state.nodes += 1;
if (state.nodes > SERVER_RENDER_JOB_BUDGET.maxSettingsNodes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the node budget");
}
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
if (Array.isArray(value)) {
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
const result = `[${value.map((item) => stableJSON(item, state)).join(",")}]`;
state.depth -= 1;
return result;
}
if (record(value)) {
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
const result = `{${Object.keys(value).sort().map((key) => {
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) || value[key] === undefined) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `render setting key ${key} is invalid`);
}
return `${JSON.stringify(key)}:${stableJSON(value[key], state)}`;
}).join(",")}}`;
state.depth -= 1;
return result;
}
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings contain a non-JSON value");
}
async function sha256(data: ArrayBuffer | string): Promise<string> {
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
}
function parseBuild(value: unknown): BlenderBuildIdentityIR {
if (!record(value) || typeof value.version !== "string" || !BUILD_VERSION.test(value.version) ||
new TextEncoder().encode(value.version).byteLength > SERVER_RENDER_JOB_BUDGET.maxBuildVersionBytes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_BUILD_INVALID", "Blender build version is invalid or outside the 5.2 contract");
}
exactKeys(value, ["version", "buildSha256"], "blenderBuild", "SERVER_RENDER_BUILD_INVALID");
return { version: value.version, buildSha256: digest(value.buildSha256, "blenderBuild.buildSha256") };
}
function parseSettings(value: unknown): ServerRenderSettingsIR {
if (!record(value)) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings must be an object");
exactKeys(value, ["renderEngine", "frameStart", "frameEnd", "resolutionX", "resolutionY", "resolutionPercentage", "samples", "outputMime", "transparent"], "settings", "SERVER_RENDER_SETTINGS_INVALID");
const renderEngine = value.renderEngine;
if (!(["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_CYCLES", "BLENDER_WORKBENCH"] as string[]).includes(renderEngine as string)) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render engine is not declared");
}
const outputMime = value.outputMime;
if (!OUTPUT_MIMES.includes(outputMime as typeof OUTPUT_MIMES[number])) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "output MIME is not declared");
}
const settings = {
...value,
renderEngine,
frameStart: boundedInteger(value.frameStart, "frameStart", -1_000_000, 1_000_000),
frameEnd: boundedInteger(value.frameEnd, "frameEnd", -1_000_000, 1_000_000),
resolutionX: boundedInteger(value.resolutionX, "resolutionX", 1, 16_384),
resolutionY: boundedInteger(value.resolutionY, "resolutionY", 1, 16_384),
resolutionPercentage: boundedInteger(value.resolutionPercentage, "resolutionPercentage", 1, 100),
samples: boundedInteger(value.samples, "samples", 1, 65_536),
outputMime,
transparent: value.transparent,
} as ServerRenderSettingsIR;
if (typeof settings.transparent !== "boolean") throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "transparent must be boolean");
if (settings.frameEnd !== settings.frameStart) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "schema 1 binds one still frame per output hash");
const encoded = new TextEncoder().encode(stableJSON(settings));
if (encoded.byteLength > SERVER_RENDER_JOB_BUDGET.maxSettingsBytes) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the byte budget");
return settings;
}
function canonicalRequest(request: Omit<ServerRenderJobRequestIR, "requestSha256">): string {
return stableJSON({
schemaVersion: request.schemaVersion,
jobId: request.jobId,
sourceBlendSha256: request.sourceBlendSha256,
sourceBlendByteLength: request.sourceBlendByteLength,
sourceRevision: request.sourceRevision,
blenderBuild: request.blenderBuild,
settingsSha256: request.settingsSha256,
});
}
function canonicalResult(result: Omit<ServerRenderJobResultIR, "resultSha256">): string {
return stableJSON(result);
}
export async function createServerRenderJobRequest(
sourceBlend: ArrayBuffer,
blenderBuildValue: unknown,
settingsValue: unknown,
options: { jobId?: string; sourceRevision?: number } = {},
): Promise<ServerRenderJobRequestIR> {
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength < 1 || sourceBlend.byteLength > SERVER_RENDER_JOB_BUDGET.maxSourceBytes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_INVALID", "source .blend bytes are empty or exceed the budget");
}
const jobId = options.jobId ?? "render-request";
if (!JOB_ID.test(jobId) || new TextEncoder().encode(jobId).byteLength > SERVER_RENDER_JOB_BUDGET.maxJobIdBytes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
}
const sourceRevision = options.sourceRevision ?? 0;
boundedInteger(sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
const blenderBuild = parseBuild(blenderBuildValue);
const settings = parseSettings(settingsValue);
const settingsSha256 = await sha256(stableJSON(settings));
const unsigned = {
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
jobId,
sourceBlendSha256: await sha256(sourceBlend),
sourceBlendByteLength: sourceBlend.byteLength,
sourceRevision,
blenderBuild,
settings,
settingsSha256,
} as Omit<ServerRenderJobRequestIR, "requestSha256">;
return { ...unsigned, requestSha256: await sha256(canonicalRequest(unsigned)) };
}
export function parseServerRenderJobRequest(value: unknown): ServerRenderJobRequestIR {
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job request schema");
exactKeys(value, ["schemaVersion", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settings", "settingsSha256", "requestSha256"], "request");
const jobId = value.jobId;
if (typeof jobId !== "string" || !JOB_ID.test(jobId)) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
const sourceBlendByteLength = boundedInteger(value.sourceBlendByteLength, "sourceBlendByteLength", 1, SERVER_RENDER_JOB_BUDGET.maxSourceBytes);
const sourceRevision = boundedInteger(value.sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
const blenderBuild = parseBuild(value.blenderBuild);
const settings = parseSettings(value.settings);
const settingsSha256 = digest(value.settingsSha256, "settingsSha256");
const requestSha256 = digest(value.requestSha256, "requestSha256");
const request = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision, blenderBuild, settings, settingsSha256, requestSha256 };
return request;
}
export async function verifyServerRenderJobRequest(value: unknown, sourceBlend?: ArrayBuffer): Promise<ServerRenderJobRequestIR> {
const request = parseServerRenderJobRequest(value);
if (await sha256(stableJSON(request.settings)) !== request.settingsSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_HASH_MISMATCH", "settings do not match settingsSha256");
const { requestSha256, ...unsigned } = request;
if (await sha256(canonicalRequest(unsigned)) !== requestSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_HASH_MISMATCH", "request binding hash does not match canonical metadata");
if (sourceBlend !== undefined) {
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength !== request.sourceBlendByteLength || await sha256(sourceBlend) !== request.sourceBlendSha256) {
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_HASH_MISMATCH", "source .blend bytes do not match the submitted request");
}
}
return request;
}
export async function createServerRenderJobResult(
requestValue: unknown,
output: ArrayBuffer,
options: { status?: "SUCCEEDED"; errorCode?: never } = {},
): Promise<ServerRenderJobResultIR> {
const request = await verifyServerRenderJobRequest(requestValue);
if (!(output instanceof ArrayBuffer) || output.byteLength < 1 || output.byteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "render output is empty or exceeds the budget");
}
const unsigned = {
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
jobId: request.jobId,
status: options.status ?? "SUCCEEDED",
sourceBlendSha256: request.sourceBlendSha256,
sourceBlendByteLength: request.sourceBlendByteLength,
sourceRevision: request.sourceRevision,
blenderBuild: request.blenderBuild,
settingsSha256: request.settingsSha256,
outputMime: request.settings.outputMime,
outputSha256: await sha256(output),
outputByteLength: output.byteLength,
requestSha256: request.requestSha256,
} as Omit<ServerRenderJobResultIR, "resultSha256">;
return { ...unsigned, resultSha256: await sha256(canonicalResult(unsigned)) };
}
export async function verifyServerRenderJobResult(value: unknown, requestValue: unknown, output?: ArrayBuffer): Promise<ServerRenderJobResultIR> {
const request = await verifyServerRenderJobRequest(requestValue);
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job result schema");
exactKeys(value, ["schemaVersion", "jobId", "status", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settingsSha256", "outputMime", "outputSha256", "outputByteLength", "requestSha256", "resultSha256", "errorCode"], "result");
const result = { ...value } as unknown as ServerRenderJobResultIR;
if (result.jobId !== request.jobId || result.requestSha256 !== request.requestSha256 || result.sourceBlendSha256 !== request.sourceBlendSha256 ||
result.sourceBlendByteLength !== request.sourceBlendByteLength || result.sourceRevision !== request.sourceRevision ||
result.settingsSha256 !== request.settingsSha256 || JSON.stringify(result.blenderBuild) !== JSON.stringify(request.blenderBuild)) {
throw new ServerRenderJobValidationError("SERVER_RENDER_BINDING_MISMATCH", "server result is bound to different source, build, settings or request");
}
if (!SERVER_RENDER_JOB_STATUSES.includes(result.status)) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status is invalid");
if (result.status === "SUCCEEDED") {
if (result.errorCode !== undefined || result.outputMime !== request.settings.outputMime || !OUTPUT_MIMES.includes(result.outputMime) || typeof result.outputByteLength !== "number" ||
!Number.isSafeInteger(result.outputByteLength) || result.outputByteLength < 1 || result.outputByteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "successful result has inconsistent output metadata");
}
digest(result.outputSha256, "outputSha256");
if (output !== undefined && (output.byteLength !== result.outputByteLength || await sha256(output) !== result.outputSha256)) {
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_HASH_MISMATCH", "render output bytes do not match outputSha256");
}
} else {
if (result.outputSha256 !== undefined || result.outputByteLength !== undefined || result.outputMime !== undefined) {
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "non-successful result must not publish output bytes");
}
const expectedErrorCode = result.status === "FAILED" ? "SERVER_RENDER_FAILED" : result.status === "CANCELLED" ? "SERVER_RENDER_CANCELLED" : undefined;
if (result.errorCode !== expectedErrorCode) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status and errorCode disagree");
}
const { resultSha256, ...unsigned } = result;
if (typeof resultSha256 !== "string" || !SHA256.test(resultSha256) || await sha256(canonicalResult(unsigned)) !== resultSha256) {
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_HASH_MISMATCH", "result binding hash does not match canonical metadata");
}
return result;
}
export async function createServerRenderJobKey(requestValue: unknown): Promise<string> {
const request = await verifyServerRenderJobRequest(requestValue);
const key = await sha256(stableJSON({ schemaVersion: SERVER_RENDER_JOB_SCHEMA, sourceBlendSha256: request.sourceBlendSha256, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime }));
return `render-${key}`;
}