Advance M8-M11 parity workflows
This commit is contained in:
313
web/protocol/server-render-job.ts
Normal file
313
web/protocol/server-render-job.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user