168 lines
8.9 KiB
JavaScript
168 lines
8.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const fixture = path.join(root, "tests/files/web/m11_render_reference.blend");
|
|
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
|
const renderScript = path.join(root, "tools/web/render-server-job.py");
|
|
const golden = JSON.parse(await fsp.readFile(path.join(root, "tests/golden/M11-06/server-render-job.json"), "utf8"));
|
|
const sourceBytes = await fsp.readFile(fixture);
|
|
const sourceArrayBuffer = sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength);
|
|
const buildSha256 = crypto.createHash("sha256").update(await fsp.readFile(blender)).digest("hex");
|
|
const versionOutput = (await execFileAsync(blender, ["--version"], { cwd: root })).stdout;
|
|
const versionLine = versionOutput.split(/\r?\n/).find((line) => line.startsWith("Blender ")) ?? "";
|
|
const buildVersion = versionLine.match(/^Blender\s+(5\.2\.[0-9]+)\b/)?.[1];
|
|
assert.equal(buildVersion, "5.2.0", `unexpected Blender build: ${versionOutput}`);
|
|
assert.equal(golden.schemaVersion, 1);
|
|
assert.equal(golden.task, "M11-06");
|
|
assert.equal(golden.blenderVersion, buildVersion);
|
|
assert.ok(fs.existsSync(fixture), "M11-04 source fixture is missing");
|
|
|
|
const requireFromWeb = createRequire(path.join(root, "web/package.json"));
|
|
const ts = requireFromWeb("typescript");
|
|
const protocolSource = fs.readFileSync(path.join(root, "web/protocol/server-render-job.ts"), "utf8")
|
|
.replace('import type { ErrorCode } from "./error";\n', "");
|
|
const transpiled = ts.transpileModule(protocolSource, {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
fileName: "server-render-job.ts",
|
|
reportDiagnostics: true,
|
|
});
|
|
assert.deepEqual(transpiled.diagnostics, []);
|
|
const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
|
|
|
|
const settings = {
|
|
renderEngine: "BLENDER_EEVEE",
|
|
frameStart: 1,
|
|
frameEnd: 1,
|
|
resolutionX: 256,
|
|
resolutionY: 256,
|
|
resolutionPercentage: 100,
|
|
samples: 1,
|
|
outputMime: "image/png",
|
|
transparent: false,
|
|
};
|
|
const build = { version: buildVersion, buildSha256 };
|
|
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "m11-server-render-job-"));
|
|
|
|
function json(response, status, value) {
|
|
const body = Buffer.from(`${JSON.stringify(value)}\n`);
|
|
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Content-Length": body.length });
|
|
response.end(body);
|
|
}
|
|
|
|
const server = http.createServer(async (request, response) => {
|
|
if (request.method !== "POST" || request.url !== "/v1/render/jobs") return json(response, 404, { code: "NOT_FOUND" });
|
|
const chunks = [];
|
|
let sourceByteLength = 0;
|
|
try {
|
|
for await (const chunk of request) {
|
|
chunks.push(chunk);
|
|
sourceByteLength += chunk.length;
|
|
if (sourceByteLength > 512 * 1024 * 1024) throw new Error("SERVER_RENDER_SOURCE_INVALID: upload exceeds budget");
|
|
}
|
|
const body = Buffer.concat(chunks);
|
|
const requestHeader = request.headers["x-render-request"];
|
|
if (typeof requestHeader !== "string") throw new Error("SERVER_RENDER_REQUEST_INVALID: missing request metadata");
|
|
const renderRequest = await protocol.verifyServerRenderJobRequest(JSON.parse(requestHeader), body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
|
|
if (renderRequest.blenderBuild.version !== build.version || renderRequest.blenderBuild.buildSha256 !== build.buildSha256) {
|
|
throw Object.assign(new Error("SERVER_RENDER_BUILD_MISMATCH: requested build is not the executable serving this job"), { code: "SERVER_RENDER_BUILD_MISMATCH" });
|
|
}
|
|
const fileStem = renderRequest.jobId.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
const sourcePath = path.join(temporary, `${fileStem}.blend`);
|
|
const outputPath = path.join(temporary, `${fileStem}.png`);
|
|
const settingsPath = path.join(temporary, `${fileStem}.json`);
|
|
await fsp.writeFile(sourcePath, body, { flag: "wx", mode: 0o600 });
|
|
await fsp.writeFile(settingsPath, `${JSON.stringify(renderRequest.settings)}\n`, { flag: "wx", mode: 0o600 });
|
|
await execFileAsync(blender, ["--background", "--factory-startup", "--python", renderScript, "--", sourcePath, outputPath, settingsPath], { cwd: root, maxBuffer: 2 * 1024 * 1024 });
|
|
const outputBytes = await fsp.readFile(outputPath);
|
|
const result = await protocol.createServerRenderJobResult(renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength));
|
|
json(response, 200, result);
|
|
} catch (error) {
|
|
json(response, 400, { code: error?.code ?? "SERVER_RENDER_FAILED", message: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
assert.ok(address && typeof address !== "string");
|
|
const origin = `http://127.0.0.1:${address.port}`;
|
|
|
|
try {
|
|
const renderRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, build, settings, { jobId: "render:m11-06", sourceRevision: 11 });
|
|
const response = await fetch(`${origin}/v1/render/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) },
|
|
body: sourceBytes,
|
|
duplex: "half",
|
|
});
|
|
const responseText = await response.text();
|
|
assert.equal(response.status, 200, responseText);
|
|
const result = JSON.parse(responseText);
|
|
const outputPath = path.join(temporary, "verified-output.png");
|
|
const outputBytes = await fsp.readFile(path.join(temporary, "render_m11-06.png"));
|
|
await fsp.writeFile(outputPath, outputBytes);
|
|
await protocol.verifyServerRenderJobResult(result, renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength));
|
|
assert.equal(result.sourceBlendSha256, renderRequest.sourceBlendSha256);
|
|
assert.equal(result.settingsSha256, renderRequest.settingsSha256);
|
|
assert.equal(result.sourceBlendSha256, golden.sourceBlendSha256);
|
|
assert.equal(result.settingsSha256, golden.settingsSha256);
|
|
assert.equal(result.outputMime, golden.output.mime);
|
|
assert.ok(result.outputByteLength >= golden.output.minimumByteLength);
|
|
assert.match(result.outputSha256, new RegExp(golden.output.sha256Pattern));
|
|
|
|
const tamperedSource = Buffer.from(sourceBytes);
|
|
tamperedSource[0] ^= 0xff;
|
|
const sourceFailure = await fetch(`${origin}/v1/render/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) },
|
|
body: tamperedSource,
|
|
duplex: "half",
|
|
});
|
|
assert.equal(sourceFailure.status, 400);
|
|
assert.equal((await sourceFailure.json()).code, golden.tamperCodes.source);
|
|
|
|
const wrongBuildRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, { ...build, buildSha256: "b".repeat(64) }, settings, { jobId: "render:wrong-build", sourceRevision: 11 });
|
|
const buildFailure = await fetch(`${origin}/v1/render/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(wrongBuildRequest) },
|
|
body: sourceBytes,
|
|
duplex: "half",
|
|
});
|
|
assert.equal(buildFailure.status, 400);
|
|
assert.equal((await buildFailure.json()).code, golden.tamperCodes.build);
|
|
|
|
const settingsFailureRequest = { ...renderRequest, settings: { ...renderRequest.settings, samples: 2 } };
|
|
const settingsFailure = await fetch(`${origin}/v1/render/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(settingsFailureRequest) },
|
|
body: sourceBytes,
|
|
duplex: "half",
|
|
});
|
|
assert.equal(settingsFailure.status, 400);
|
|
assert.equal((await settingsFailure.json()).code, golden.tamperCodes.settings);
|
|
|
|
const tamperedOutput = Buffer.from(outputBytes);
|
|
tamperedOutput[tamperedOutput.length - 1] ^= 0xff;
|
|
await assert.rejects(
|
|
protocol.verifyServerRenderJobResult(result, renderRequest, tamperedOutput.buffer.slice(tamperedOutput.byteOffset, tamperedOutput.byteOffset + tamperedOutput.byteLength)),
|
|
{ code: golden.tamperCodes.output },
|
|
);
|
|
process.stdout.write(`server-render-job-ok blender=${buildVersion} source=${renderRequest.sourceBlendSha256} settings=${renderRequest.settingsSha256} output=${result.outputSha256} bytes=${result.outputByteLength} source-hash=1 build-hash=1 settings-hash=1 output-hash=1 tamper=4\n`);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await fsp.rm(temporary, { recursive: true, force: true });
|
|
}
|