feat(web): complete Blender route runtime execution
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 00:12:32 +08:00
parent e2e81c7c00
commit e9bfef0c5a
35 changed files with 57527 additions and 75 deletions

View File

@@ -37,8 +37,8 @@ import type {
} from "../../../protocol/paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
import { parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR } from "../../../protocol/server-operator-job";
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType, BlenderServerOperatorSchema } from "../../../protocol/blender-operator";
import { canonicalServerOperatorJobRequest, canonicalServerOperatorProperties, encodeServerOperatorJobEnvelope, parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR, type ServerOperatorSchemaResultIR } from "../../../protocol/server-operator-job";
import featureRouteCatalog from "../capabilities/blender-feature-route-catalog.json";
interface PendingRequest {
@@ -54,6 +54,7 @@ export interface WebEngineClientOptions {
timeoutMs?: number;
workerFactory?: () => Worker;
onWorkerFault?: (fault: WorkerFault) => void;
serverOperatorEndpoint?: string;
}
const defaultWorkerFactory = () =>
@@ -118,9 +119,9 @@ function normalizeOperatorCatalog(value: unknown): BlenderOperatorDescriptor[] {
function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[] {
const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
const entries = Array.isArray(value) ? value : root && (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []);
const entries: unknown[] = Array.isArray(value) ? value : root ? (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []) : [];
return entries.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))).map((entry) => {
const route = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
const route: BlenderFeatureRouteEntry["route"] = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
const locations = Array.isArray(entry.locations) ? entry.locations.filter((location): location is Record<string, unknown> => Boolean(location && typeof location === "object" && !Array.isArray(location))).map((location) => ({
path: String(location.path ?? ""),
...(typeof location.line === "number" ? { line: location.line } : {}),
@@ -143,6 +144,27 @@ function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[
const FEATURE_ROUTE_CATALOG = normalizeFeatureRouteCatalog(featureRouteCatalog);
async function sha256Hex(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 decodeBase64(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
async function readServerOperatorPayload(response: Response): Promise<{ status?: unknown; result?: unknown }> {
let payload: { status?: unknown; result?: unknown };
try { payload = await response.json() as { status?: unknown; result?: unknown }; }
catch { throw new Error(`SERVER_OPERATOR_HTTP_FAILED: ${response.status}`); }
if (!response.ok) throw new Error(String((payload.result as { errorMessage?: unknown } | undefined)?.errorMessage ?? `SERVER_OPERATOR_HTTP_FAILED: ${response.status}`));
if (!payload.result || typeof payload.result !== "object") throw new Error("SERVER_OPERATOR_HTTP_INVALID_RESULT");
return payload;
}
function mergeFeatureRoutes(operators: BlenderOperatorDescriptor[]): BlenderFeatureRouteEntry[] {
const merged = FEATURE_ROUTE_CATALOG.map((route) => ({ ...route }));
for (const operator of operators) {
@@ -179,11 +201,16 @@ export class WebEngineClient {
private pending = new Map<string, PendingRequest>();
private geometryBuffers: MeshGeometryBuffer[] = [];
private nonMeshGeometryBuffers: NonMeshGeometryChunk[] = [];
private readonly serverOperatorEndpoint: string;
private readonly serverOperatorSchemaCache = new Map<string, BlenderServerOperatorSchema>();
private readonly serverOperatorSchemaIndex = new Map<string, string>();
constructor(options: WebEngineClientOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 30_000;
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
this.onWorkerFault = options.onWorkerFault;
const configuredEndpoint = options.serverOperatorEndpoint ?? (globalThis as { __BLENDER_SERVER_OPERATOR_ENDPOINT__?: unknown }).__BLENDER_SERVER_OPERATOR_ENDPOINT__;
this.serverOperatorEndpoint = typeof configuredEndpoint === "string" && configuredEndpoint.trim() ? configuredEndpoint : "/api/server-operator-job";
}
async init(): Promise<WebEngineStatus> {
@@ -269,8 +296,9 @@ export class WebEngineClient {
async readRNAPropertySchema(params: Record<string, unknown>): Promise<{ status: WebEngineStatus; schema: RNAPropertyDescriptor }> {
const { status, result } = await this.callJson<unknown>("readRNAPropertySchema", params);
const schema = result && typeof result === "object" && result.schema && typeof result.schema === "object"
? result.schema as RNAPropertyDescriptor
const resultRecord = result && typeof result === "object" ? result as { schema?: unknown } : null;
const schema = resultRecord?.schema && typeof resultRecord.schema === "object"
? resultRecord.schema as RNAPropertyDescriptor
: result as RNAPropertyDescriptor;
return { status, schema };
}
@@ -285,8 +313,9 @@ export class WebEngineClient {
async readRNACollection(params: Record<string, unknown> = {}): Promise<{ status: WebEngineStatus; collection: RNACollectionDescriptor }> {
const { status, result } = await this.callJson<unknown>("readRNACollection", params);
const collection = result && typeof result === "object" && result.collection && typeof result.collection === "object"
? result.collection as RNACollectionDescriptor
const resultRecord = result && typeof result === "object" ? result as { collection?: unknown } : null;
const collection = resultRecord?.collection && typeof resultRecord.collection === "object"
? resultRecord.collection as RNACollectionDescriptor
: result as RNACollectionDescriptor;
return { status, collection };
}
@@ -320,28 +349,100 @@ export class WebEngineClient {
});
}
if (route.route === "SERVER_NATIVE") {
if (!invocation.serverJob) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE feature routes require a typed server job payload", true);
parseServerOperatorJobRequest(invocation.serverJob);
const { status, result } = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", invocation.serverJob as unknown as Record<string, unknown>);
return { status, snapshot: result as unknown as BlendOpenResult["snapshot"], delta: result as unknown as SceneDelta, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], operatorResult: result };
if (!invocation.serverJob) await this.describeServerOperator(route.identifier);
const request = invocation.serverJob ?? await this.createServerOperatorJobRequest(route.identifier, invocation.properties ?? {});
const submitted = await this.submitServerOperatorJob(request);
const opened = await this.snapshot();
const delta = await this.delta();
return { ...opened, delta: delta.delta, operatorResult: submitted.job };
}
throw this.report("INVALID_ARGUMENT", `Feature route ${route.identifier} is not executable in the current browser context`, true);
}
async describeServerOperator(operator: string): Promise<{ status: WebEngineStatus; schema: BlenderServerOperatorSchema }> {
const normalizedOperator = operator.replace(/^operator:/, "").trim();
if (!normalizedOperator || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(normalizedOperator)) {
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE 操作标识无效", true);
}
const cachedKey = this.serverOperatorSchemaIndex.get(normalizedOperator);
if (cachedKey) {
const cached = this.serverOperatorSchemaCache.get(cachedKey);
if (cached) return { status: { ready: true } as WebEngineStatus, schema: cached };
}
const response = await fetch(this.serverOperatorEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "describe" },
body: JSON.stringify({ operator: normalizedOperator }),
});
const payload = await readServerOperatorPayload(response);
const result = payload.result as ServerOperatorSchemaResultIR;
if (!result || result.route !== "SERVER_NATIVE" || !result.operator || !result.blenderBuild) {
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 响应无效", true);
}
const descriptor = normalizeOperatorCatalog([result.operator])[0];
if (!descriptor) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 操作无效", true);
const schema = { operator: descriptor, blenderBuild: result.blenderBuild };
const cacheKey = `${normalizedOperator}|${result.blenderBuild.version}|${result.blenderBuild.buildSha256}`;
this.serverOperatorSchemaCache.set(cacheKey, schema);
this.serverOperatorSchemaIndex.set(normalizedOperator, cacheKey);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, schema };
}
async submitServerOperatorJob(request: ServerOperatorJobRequestIR): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const validated = parseServerOperatorJobRequest(request);
const result = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", validated as unknown as Record<string, unknown>);
return { status: result.status, job: result.result };
const source = await this.saveBlend();
const sourceSha256 = await sha256Hex(source);
if (source.byteLength !== validated.sourceBlendByteLength || sourceSha256 !== validated.sourceBlendSha256) throw this.report("BLEND_READ_FAILED", "当前 Main 与 SERVER_NATIVE 请求绑定不一致", true);
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Server-Operator-Action": "submit" }, body: encodeServerOperatorJobEnvelope(validated, source) });
const payload = await readServerOperatorPayload(response);
const job = payload.result as ServerOperatorJobResultIR;
if (job.status === "SUCCEEDED" && job.resultBlendBase64) {
const bytes = decodeBase64(job.resultBlendBase64);
const resultBuffer = bytes.slice().buffer as ArrayBuffer;
if (job.resultBlendByteLength !== bytes.byteLength || (job.resultBlendSha256 && job.resultBlendSha256 !== await sha256Hex(resultBuffer))) throw this.report("BLEND_READ_FAILED", "SERVER_NATIVE 结果 .blend 校验失败", true);
await this.openBlend(resultBuffer);
}
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job };
}
async cancelServerOperatorJob(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const result = await this.callJson<ServerOperatorJobResultIR>("cancelServerOperatorJob", { jobId, requestSha256 });
return { status: result.status, job: result.result };
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "cancel" }, body: JSON.stringify({ jobId, requestSha256 }) });
const payload = await readServerOperatorPayload(response);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
}
async readServerOperatorResult(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const result = await this.callJson<ServerOperatorJobResultIR>("readServerOperatorResult", { jobId, requestSha256 });
return { status: result.status, job: result.result };
const query = new URLSearchParams({ jobId, requestSha256 });
const response = await fetch(`${this.serverOperatorEndpoint}?${query.toString()}`, { headers: { "X-Server-Operator-Action": "read" } });
const payload = await readServerOperatorPayload(response);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
}
private async createServerOperatorJobRequest(operator: string, properties: Record<string, unknown>): Promise<ServerOperatorJobRequestIR> {
const source = await this.saveBlend();
const sourceBlendSha256 = await sha256Hex(source);
const props = properties as ServerOperatorJobRequestIR["properties"];
const propertiesSha256 = await sha256Hex(new TextEncoder().encode(canonicalServerOperatorProperties(props)).buffer);
const snapshot = await this.snapshot();
const schemaKey = this.serverOperatorSchemaIndex.get(operator.replace(/^operator:/, ""));
const schemaBuild = schemaKey ? this.serverOperatorSchemaCache.get(schemaKey)?.blenderBuild : undefined;
const base = {
schemaVersion: 1 as const,
route: "SERVER_NATIVE" as const,
jobId: `server-${Date.now()}-${++this.requestCounter}`,
sourceBlendSha256,
sourceBlendByteLength: source.byteLength,
sourceRevision: snapshot.snapshot.revision,
blenderBuild: schemaBuild ?? { version: "web-engine", buildSha256: await sha256Hex(new TextEncoder().encode("web-engine").buffer) },
operator: operator.replace(/^operator:/, ""),
properties: props,
propertiesSha256,
timeoutMs: 10 * 60 * 1000,
memoryBytes: 512 * 1024 * 1024,
resultBinding: { kind: "json" as const, key: "result" },
context: "BACKGROUND" as const,
};
return { ...base, requestSha256: await sha256Hex(new TextEncoder().encode(canonicalServerOperatorJobRequest(base)).buffer) };
}
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {