chore: checkpoint web Blender migration source, tools and docs
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

Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-10 11:18:01 +08:00
parent 122142c26c
commit f3e21c6b98
6887 changed files with 257154 additions and 12294 deletions

View File

@@ -0,0 +1,257 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
import {startBoundedServerJobProcess,startServerJobProcess,cancelServerJobProcess} from './server-job-process.mjs';
import {createResourceBundle,materializeResources,releaseResourceBundle,captureResourceBundle,exportResourceBundle,describeResourceBundle} from './native-resources.mjs';
import {handleNativeSession} from './native-session-server.mjs';
import {nativeSessionWorkspaceRoot} from './native-session-workspace.mjs';
import {prepareNativePreferences,nativeProfileDirectories} from './native-preferences.mjs';
const ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
const jobs=new Map();
const MAX_SOURCE=512*1024*1024, MAX_ARTIFACTS=256*1024*1024;
const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
const canonical=value=>Array.isArray(value)?`[${value.map(canonical).join(',')}]`:value&&typeof value==='object'?`{${Object.keys(value).sort().map(k=>`${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`:JSON.stringify(value);
const fail=(message,statusCode=400)=>{throw Object.assign(new Error(message),{statusCode,errorCode:message});};
const json=(res,status,data)=>{res.statusCode=status;res.setHeader('Content-Type','application/json');res.setHeader('Cache-Control','no-store');res.end(JSON.stringify(data));};
const executable=name=>process.env.PATH?.split(path.delimiter).map(dir=>path.join(dir,name)).find(p=>{try{fs.accessSync(p,fs.constants.X_OK);return true;}catch{return false;}});
const terminal=(entry,status,error)=>({schemaVersion:1,jobId:entry.request.jobId,kind:entry.request.kind,requestSha256:entry.request.requestSha256,sourceBlendSha256:entry.request.sourceBlendSha256,sourceRevision:entry.request.sourceRevision,status,...(error?{error,...(/^([A-Z][A-Z0-9_.-]*)(?::|$)/u.exec(error)?.[1]?{errorCode:/^([A-Z][A-Z0-9_.-]*)(?::|$)/u.exec(error)?.[1]}:{})}: {})});
function cleanup(entry){if(entry.workspace){fs.rmSync(entry.workspace,{recursive:true,force:true});entry.workspace=null;}}
function workspaceUsage(directory,state={bytes:0,files:0}){
for(const entry of fs.readdirSync(directory,{withFileTypes:true})){
if(++state.files>10000)throw new Error('NATIVE_WORKSPACE_FILE_BUDGET');
const file=path.join(directory,entry.name);let info;
try{info=fs.lstatSync(file);}catch(error){if(error.code==='ENOENT')continue;throw error;}
if(info.isDirectory())workspaceUsage(file,state);
else if(info.isFile())state.bytes+=info.size;
if(state.bytes>1024*1024*1024)throw new Error('NATIVE_WORKSPACE_BYTE_BUDGET');
}
return state;
}
function collect(req,limit){return new Promise((resolve,reject)=>{const chunks=[];let length=0;req.on('data',chunk=>{length+=chunk.length;if(length>limit){reject(new Error('NATIVE_REQUEST_TOO_LARGE'));req.destroy();}else chunks.push(chunk);});req.on('end',()=>resolve(Buffer.concat(chunks)));req.on('error',reject);});}
function integer(value,min,max){return Number.isSafeInteger(value)&&value>=min&&value<=max;}
function blenderBinary(options){
const configured=options.blenderBinary??process.env.BLENDER_BIN;
if(configured&&configured!=='blender')return configured;
const adapter=path.join(ROOT,'build_blender_native/desktop-adapter/bin/blender');
if(fs.existsSync(adapter))return adapter;
const local=path.join(ROOT,'build_blender_native/bin/blender');
return fs.existsSync(local)?local:executable('blender')??'blender';
}
function boundedString(value,max){return typeof value==='string'&&Buffer.byteLength(value)<=max&&!value.includes('\0');}
function validate(request){
if(request?.schemaVersion!==1||!['RENDER','PYTHON','DESKTOP_OPERATOR','EXPORT'].includes(request.kind))fail('NATIVE_REQUEST_KIND');
if(!/^[a-zA-Z0-9_-]{1,128}$/.test(request.jobId??'')||!integer(request.sourceRevision,0,Number.MAX_SAFE_INTEGER)||!integer(request.sourceBlendByteLength,1,MAX_SOURCE)||!integer(request.timeoutMs,1000,600000))fail('NATIVE_REQUEST_BUDGET');
for(const key of ['sourceBlendSha256','requestSha256'])if(!/^[a-f0-9]{64}$/.test(request[key]??''))fail('NATIVE_REQUEST_HASH');
const {requestSha256,...unsigned}=request;if(hash(Buffer.from(canonical(unsigned)))!==requestSha256)fail('NATIVE_REQUEST_BINDING');
if(request.resources&&(!/^[a-f0-9]{64}$/.test(request.resources.token??'')||!/^[a-f0-9]{64}$/.test(request.resources.sha256??'')))fail('NATIVE_RESOURCE_BINDING');
if(request.desktop&&(!integer(request.desktop.width,640,2560)||!integer(request.desktop.height,360,1600)||typeof request.desktop.preserveLayout!=='boolean'||request.desktop.useSavedStartup!==undefined&&typeof request.desktop.useSavedStartup!=='boolean'))fail('NATIVE_DESKTOP_SIZE');
if(request.desktop?.recoveryKey!==undefined&&!/^[a-f0-9-]{36}$/.test(request.desktop.recoveryKey))fail('NATIVE_RECOVERY_KEY');
if(request.desktop?.recoverySavedAt!==undefined&&!integer(request.desktop.recoverySavedAt,0,Number.MAX_SAFE_INTEGER))fail('NATIVE_RECOVERY_TIMESTAMP');
const context=request.context;if(!context||typeof context!=='object'||Array.isArray(context))fail('NATIVE_CONTEXT_INVALID');
for(const key of ['sceneName','activeObject','nodeTreeName'])if(context[key]!==undefined&&!boundedString(context[key],256))fail('NATIVE_CONTEXT_NAME');
if(context.nodeTreeType!==undefined&&!['ShaderNodeTree','GeometryNodeTree','CompositorNodeTree'].includes(context.nodeTreeType))fail('NATIVE_NODE_EDITOR_TYPE');
if(context.selectedObjects!==undefined&&(!Array.isArray(context.selectedObjects)||context.selectedObjects.length>4096||context.selectedObjects.some(v=>!boundedString(v,256))))fail('NATIVE_SELECTION_INVALID');
if(context.mode!==undefined&&!['OBJECT','EDIT','POSE','SCULPT'].includes(context.mode))fail('NATIVE_MODE_INVALID');
if(context.meshSelection!==undefined){const s=context.meshSelection;if(!s||!['VERT','EDGE','FACE'].includes(s.domain)||!Array.isArray(s.indices)||s.indices.length>50000||s.indices.some(i=>!integer(i,0,100000000)))fail('NATIVE_MESH_SELECTION_INVALID');}
if(context.areaType!==undefined&&!['VIEW_3D','IMAGE_EDITOR','NODE_EDITOR','GRAPH_EDITOR','DOPESHEET_EDITOR','SEQUENCE_EDITOR','TEXT_EDITOR','NLA_EDITOR','CLIP_EDITOR','CONSOLE','INFO','OUTLINER','PROPERTIES','FILE_BROWSER','SPREADSHEET','PREFERENCES'].includes(context.areaType))fail('NATIVE_AREA_INVALID');
if(request.kind==='RENDER'){
const r=request.render;
if(!r||!integer(r.frameStart,-1000000,1000000)||!integer(r.frameEnd,r.frameStart,1000000)||!integer(r.step,1,1000)||Math.floor((r.frameEnd-r.frameStart)/r.step)+1>240||!['PNG','OPEN_EXR','FFMPEG'].includes(r.format))fail('NATIVE_RENDER_RANGE');
if(r.engine!==undefined&&!['SCENE','CYCLES','BLENDER_EEVEE','BLENDER_WORKBENCH'].includes(r.engine))fail('NATIVE_RENDER_ENGINE');
if(r.resolutionPercentage!==undefined&&!integer(r.resolutionPercentage,1,100))fail('NATIVE_RENDER_RESOLUTION');
}else if(request.kind==='PYTHON'){
if(!request.python||!boundedString(request.python.source,65536)||!boundedString(request.python.textName,128)||!request.python.textName||typeof request.python.console!=='boolean')fail('NATIVE_PYTHON_INVALID');
}else if(request.kind==='EXPORT'){
const spec=request.export;
if(!spec||!['GLB','GLTF','FBX','OBJ','STL','PLY','USD','ALEMBIC'].includes(spec.format)||typeof spec.selectedOnly!=='boolean')fail('NATIVE_EXPORT_FORMAT');
const scalar=v=>typeof v==='boolean'||typeof v==='number'&&Number.isFinite(v)||boundedString(v,4096);
if(!spec.properties||typeof spec.properties!=='object'||Array.isArray(spec.properties)||Object.keys(spec.properties).length>128||Object.keys(spec.properties).some(key=>['filepath','directory','filename','files','check_existing','filter_glob','batch_mode','use_batch_own_dir','as_background_job','export_format','export_texture_dir','export_animation','use_selection','export_selected_objects','selected_objects_only','selected'].includes(key)||/(path|directory|filename)/iu.test(key))||Object.entries(spec.properties).some(([key,value])=>!/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(key)||!(scalar(value)||Array.isArray(value)&&value.length<=64&&value.every(scalar)))||Buffer.byteLength(JSON.stringify(spec.properties))>65536)fail('NATIVE_EXPORT_PROPERTIES');
}else{
const op=request.operator;
if(!op||!boundedString(op.id,128)||!/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/.test(op.id))fail('NATIVE_OPERATOR_INVALID');
if(/^(wm|window|screen|file)\./.test(op.id))fail('NATIVE_OPERATOR_LIFECYCLE_BLOCKED');
const props=op.properties;
if(!props||typeof props!=='object'||Array.isArray(props)||Object.keys(props).length>128)fail('NATIVE_OPERATOR_PROPERTIES');
const scalar=v=>typeof v==='boolean'||typeof v==='number'&&Number.isFinite(v)||boundedString(v,4096);
for(const [k,v] of Object.entries(props))if(!/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(k)||!(scalar(v)||Array.isArray(v)&&v.length<=64&&v.every(scalar)))fail('NATIVE_OPERATOR_PROPERTY');
}
}
function localRequest(req){
const address=req.socket.remoteAddress??'';
const url=new URL(req.url??'/','http://'+req.headers.host);
if(!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(address)||!['localhost','127.0.0.1','[::1]'].includes(url.hostname))fail('NATIVE_LOCAL_SERVICE_ONLY',403);
if(req.headers.origin&&new URL(req.headers.origin).host!==req.headers.host)fail('NATIVE_ORIGIN_REJECTED',403);
if(req.headers['x-blender-workflow']!=='1')fail('NATIVE_EXPLICIT_REQUEST_REQUIRED',403);
}
async function finish(entry){
try{
await entry.process.completion;
if(entry.result)return;
if(entry.process.receipt.cancelRequested)throw new Error('NATIVE_JOB_TIMEOUT');
if(entry.process.child.exitCode!==0)throw new Error('NATIVE_PROCESS_FAILED: '+entry.stderr.slice(-4096));
const p=path.join(entry.workspace,'result.json');
if(fs.lstatSync(p).isSymbolicLink()||fs.statSync(p).size>2*1024*1024)throw new Error('NATIVE_RESULT_INVALID');
const result=JSON.parse(fs.readFileSync(p,'utf8'));if(result.status==='FAILED'&&typeof result.error==='string'){const code=/^([A-Z][A-Z0-9_.-]*)(?::|$)/u.exec(result.error)?.[1];if(code)result.errorCode=code;}
for(const key of ['schemaVersion','jobId','kind','requestSha256','sourceBlendSha256','sourceRevision'])if(result[key]!==entry.request[key])throw new Error('NATIVE_RESULT_BINDING');
if(!['SUCCEEDED','FAILED'].includes(result.status))throw new Error('NATIVE_RESULT_STATUS');
if(result.status==='SUCCEEDED'){
let total=0;const ids=new Set();
if(!Array.isArray(result.artifacts)||result.artifacts.length>242)throw new Error('NATIVE_RESULT_ARTIFACTS');
for(const artifact of result.artifacts){
if(!/^[a-zA-Z0-9_.-]+$/.test(artifact.id)||artifact.id.startsWith('.')||ids.has(artifact.id))throw new Error('NATIVE_ARTIFACT_NAME');ids.add(artifact.id);
const file=path.join(entry.workspace,artifact.id),stat=fs.lstatSync(file);
if(!stat.isFile()||stat.isSymbolicLink()||stat.size<1||stat.size!==artifact.bytes||total+stat.size>MAX_ARTIFACTS)throw new Error('NATIVE_ARTIFACT_SIZE');
if(hash(fs.readFileSync(file))!==artifact.sha256)throw new Error('NATIVE_ARTIFACT_HASH');total+=stat.size;
}
if(['PYTHON','DESKTOP_OPERATOR'].includes(entry.request.kind)&&!ids.has('result.blend'))throw new Error('NATIVE_RESULT_BLEND_MISSING');
if(entry.request.kind==='EXPORT'&&!ids.has('export-package.zip'))throw new Error('NATIVE_EXPORT_EMPTY');
if(entry.request.kind==='RENDER'&&!result.artifacts.some(a=>a.id.startsWith('frame-')||a.mime==='video/mp4'))throw new Error('NATIVE_RENDER_EMPTY');
if(entry.request.kind!=='EXPORT')result.resources=captureResourceBundle(entry.workspace);
}
entry.result=result;
if(result.status==='FAILED')try{cleanup(entry);}catch(error){entry.cleanupError=String(error?.message??error);console.error('NATIVE_WORKSPACE_CLEANUP_FAILED',entry.request.jobId,entry.cleanupError);}
}catch(error){entry.result??=terminal(entry,'FAILED',String(error.message??error));try{cleanup(entry);}catch(cleanupError){entry.cleanupError=String(cleanupError?.message??cleanupError);console.error('NATIVE_WORKSPACE_CLEANUP_FAILED',entry.request.jobId,entry.cleanupError);}}
finally{
clearInterval(entry.monitor);
const expire=(retry=0)=>{if(jobs.get(entry.request.jobId)!==entry)return;try{cleanup(entry);jobs.delete(entry.request.jobId);}catch(error){entry.cleanupError=String(error?.message??error);console.error('NATIVE_WORKSPACE_CLEANUP_FAILED',entry.request.jobId,{retry,message:entry.cleanupError});if(retry<3){entry.expiry=setTimeout(()=>expire(retry+1),60000*2**retry);entry.expiry.unref();}}};
entry.expiry=setTimeout(expire,30*60*1000);entry.expiry.unref();
}
}
function launch(workspace,request,options,persistent=false,sessionWorkspace){
const sandbox=executable('bwrap');if(!sandbox)fail('NATIVE_SANDBOX_NOT_INSTALLED',503);
const blender=blenderBinary(options);
const gui=persistent||['DESKTOP_OPERATOR','EXPORT'].includes(request.kind)||request.kind==='RENDER'&&request.render.engine!=='CYCLES';
fs.mkdirSync(path.join(workspace,'runtime'),{mode:0o700});
const config=prepareNativePreferences(workspace);
const profileDirectories=nativeProfileDirectories();
if(persistent&&request.desktop?.preserveLayout&&request.desktop.recoveryKey){
const recoveryRoot=path.join(profileDirectories.datafiles,'project-saves',request.desktop.recoveryKey),latest=path.join(recoveryRoot,'latest.json');
if(fs.existsSync(latest)){
const metadata=JSON.parse(fs.readFileSync(latest,'utf8'));
if(metadata.savedAt>(request.desktop.recoverySavedAt??0)){
if(!/^[a-f0-9]{32}$/.test(metadata.directory)||!/^[a-zA-Z0-9_.-]+\.blend$/.test(metadata.main))fail('NATIVE_RECOVERY_METADATA');
const directory=path.join(recoveryRoot,metadata.directory),source=path.join(directory,metadata.main),data=fs.readFileSync(source);
if(data.length>256*1024*1024||hash(data)!==metadata.sha256)fail('NATIVE_RECOVERY_SOURCE');
const resources=path.join(workspace,'resources');if(!sessionWorkspace?.retained){fs.rmSync(resources,{recursive:true,force:true});fs.mkdirSync(resources);}
const files=[];let total=0;
const restore=folder=>{for(const item of fs.readdirSync(folder,{withFileTypes:true})){
const absolute=path.join(folder,item.name);if(absolute===source)continue;
const stat=fs.lstatSync(absolute);if(stat.isSymbolicLink())fail('NATIVE_RECOVERY_SYMLINK');
if(stat.isDirectory())restore(absolute);
else if(stat.isFile()){
if((total+=stat.size)>256*1024*1024||files.length>=4096)fail('NATIVE_RECOVERY_RESOURCE_BUDGET');
const relative=path.relative(directory,absolute),destination=path.join(resources,relative),bytes=fs.readFileSync(absolute);
fs.mkdirSync(path.dirname(destination),{recursive:true});fs.writeFileSync(destination,bytes);
files.push({path:relative.split(path.sep).join('/'),bytes:bytes.length,sha256:hash(bytes)});
}
}};if(!sessionWorkspace?.retained)restore(directory);fs.mkdirSync(path.join(resources,'user'),{recursive:true});
fs.writeFileSync(path.join(workspace,'source.blend'),data);if(!sessionWorkspace?.retained)fs.writeFileSync(path.join(workspace,'resources.json'),JSON.stringify({files}));
fs.writeFileSync(path.join(workspace,'desktop-recovery.json'),JSON.stringify({savedAt:metadata.savedAt,sha256:metadata.sha256}));
}
}
}
const args=['--die-with-parent','--new-session','--unshare-all','--ro-bind','/','/','--tmpfs','/tmp','--dir','/tmp/.X11-unix','--tmpfs','/run/user','--proc','/proc','--dev','/dev','--tmpfs',nativeSessionWorkspaceRoot(),'--bind',workspace,workspace,
'--unsetenv','WAYLAND_DISPLAY','--unsetenv','DISPLAY','--unsetenv','XAUTHORITY','--unsetenv','DBUS_SESSION_BUS_ADDRESS',
'--setenv','XDG_RUNTIME_DIR',path.join(workspace,'runtime'),
'--setenv','BLENDER_USER_CONFIG',config,
'--setenv','HOME',workspace,'--setenv','TMPDIR',workspace,'--setenv','PYTHONNOUSERSITE','1','--setenv','LIBGL_ALWAYS_SOFTWARE','1',
'--setenv','__EGL_VENDOR_LIBRARY_FILENAMES','/usr/share/glvnd/egl_vendor.d/50_mesa.json','--chdir',workspace];
for(const [name,directory] of Object.entries(profileDirectories)){
if(persistent)args.push('--bind',directory,directory);
args.push('--setenv',`BLENDER_USER_${name.toUpperCase()}`,directory);
}
const prlimit=executable('prlimit');if(!prlimit)fail('NATIVE_RESOURCE_LIMITER_NOT_INSTALLED',503);
args.push(prlimit,'--as=8589934592','--fsize=536870912',persistent?'--cpu=7200':'--cpu=600','--');
const width=persistent?request.desktop?.width??1280:1280,height=persistent?request.desktop?.height??900:900;
if(gui){const xvfb=executable('xvfb-run');if(!xvfb)fail('NATIVE_VIRTUAL_DISPLAY_NOT_INSTALLED',503);args.push(xvfb,'-a',`--server-args=-screen 0 ${width}x${height}x24 -nolisten tcp`);}
if(persistent)args.push('/usr/bin/python3',path.join(ROOT,'tools/web/native-desktop-host.py'),workspace);
args.push(blender,'--factory-startup','--disable-autoexec');
if(persistent)args.push('--window-geometry','0','0',String(width),String(height));
if(!gui)args.push('--background');
args.push('--python-exit-code','1','--python',path.join(ROOT,persistent?'tools/web/native-session-blender.py':'tools/web/native-workflow-blender.py'),'--',workspace);
return (persistent?startServerJobProcess:startBoundedServerJobProcess)(sandbox,args,{cwd:workspace,timeoutMs:request.timeoutMs,stdio:['ignore','pipe','pipe']});
}
export async function handleNativeWorkflowRequest(req,res,options={}){
if(await handleNativeSession(req,res,options,{localRequest,collect,json,fail,validate,launch,workspaceUsage,canonical}))return true;
const url=new URL(req.url??'/','http://localhost');
if(url.pathname==='/api/blender-resources'){
try{localRequest(req);if(req.method!=='POST')fail('RESOURCE_METHOD',405);
if(req.headers['x-blender-resource-action']==='release'){releaseResourceBundle(JSON.parse((await collect(req,4096)).toString()));json(res,200,{status:'RELEASED'});}
else if(req.headers['x-blender-resource-action']==='describe')json(res,200,describeResourceBundle(JSON.parse((await collect(req,4096)).toString())));
else if(req.headers['x-blender-resource-action']==='export'){
const data=exportResourceBundle(JSON.parse((await collect(req,4096)).toString()));
res.statusCode=200;res.setHeader('Content-Type','application/octet-stream');res.setHeader('Cache-Control','no-store');res.end(data);
}
else json(res,200,createResourceBundle(await collect(req,256*1024*1024+2*1024*1024)));return true;
}catch(error){if(error.statusCode===405)res.setHeader('Allow','POST');json(res,error.statusCode??400,{status:'FAILED',error:String(error.message??error),...(typeof error.errorCode==='string'?{errorCode:error.errorCode}:{})});return true;}
}
if(url.pathname!=='/api/blender-workflow')return false;
const action=req.headers['x-blender-workflow-action']??'submit';
try{
localRequest(req);
if(action==='describe'){
if(req.method!=='POST')fail('NATIVE_METHOD',405);
const body=JSON.parse((await collect(req,4096)).toString());
if(!boundedString(body.operator,128)||!/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/.test(body.operator))fail('NATIVE_OPERATOR_INVALID');
const handle=startBoundedServerJobProcess(options.pythonBinary??'python3',[path.join(ROOT,'tools/web/server-operator-job.py')],{timeoutMs:120000,stdio:['pipe','pipe','pipe']});
let text='',overflow=false;handle.child.stdout.on('data',chunk=>{if(text.length+chunk.length>2*1024*1024){overflow=true;void cancelServerJobProcess(handle,async()=>{});}else text+=chunk;});
handle.child.stdin.on('error',()=>{});handle.child.stdin.end(JSON.stringify({action:'describe-context',operator:body.operator,blenderBinary:blenderBinary(options)}));
await handle.completion;
if(overflow||handle.child.exitCode!==0)fail('NATIVE_SCHEMA_FAILED',422);
const schema=JSON.parse(text);if(schema.status==='FAILED')fail(schema.errorMessage??'NATIVE_SCHEMA_FAILED',422);
json(res,200,schema);return true;
}
if(action==='submit'){
if(req.method!=='POST')fail('NATIVE_METHOD',405);
const data=await collect(req,MAX_SOURCE+256*1024+12);
if(data.length<12||data.subarray(0,4).toString()!=='BWF1')fail('NATIVE_ENVELOPE');
const metadataLength=data.readUInt32LE(4),sourceLength=data.readUInt32LE(8);
if(metadataLength>256*1024||sourceLength>MAX_SOURCE||12+metadataLength+sourceLength!==data.length)fail('NATIVE_ENVELOPE_LENGTH');
const request=JSON.parse(data.subarray(12,12+metadataLength).toString());validate(request);
const source=data.subarray(12+metadataLength);
if(source.length!==request.sourceBlendByteLength||hash(source)!==request.sourceBlendSha256)fail('NATIVE_SOURCE_BINDING');
if(jobs.has(request.jobId))fail('NATIVE_DUPLICATE_JOB',409);
if([...jobs.values()].filter(e=>!e.result).length>=2)fail('NATIVE_JOB_CAPACITY',429);
const workspace=fs.mkdtempSync(path.join(os.tmpdir(),'blender-workflow-'));
let processHandle;
try{fs.writeFileSync(path.join(workspace,'request.json'),JSON.stringify(request),{mode:0o600});fs.writeFileSync(path.join(workspace,'source.blend'),source,{mode:0o600});materializeResources(request.resources,workspace);processHandle=launch(workspace,request,options);}
catch(error){fs.rmSync(workspace,{recursive:true,force:true});throw error;}
const entry={workspace,request,process:processHandle,result:null,stderr:''};jobs.set(request.jobId,entry);
processHandle.child.stdout.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-16384);});
entry.monitor=setInterval(()=>{
if(entry.result||!entry.workspace)return;
try{workspaceUsage(entry.workspace);}
catch(error){entry.result=terminal(entry,'FAILED',String(error.message));void cancelServerJobProcess(entry.process,()=>cleanup(entry),{graceMs:500});}
},1000);entry.monitor.unref();
processHandle.child.stderr.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-16384);});
processHandle.child.on('error',error=>{entry.stderr=String(error);});
void finish(entry);json(res,202,terminal(entry,'RUNNING'));return true;
}
const body=req.method==='POST'?JSON.parse((await collect(req,4096)).toString()):{};
const id=body.jobId??url.searchParams.get('jobId'),binding=body.requestSha256??url.searchParams.get('requestSha256');
const entry=jobs.get(id);if(!entry||entry.request.requestSha256!==binding)fail('NATIVE_JOB_NOT_FOUND',404);
if(action==='cancel'||action==='release'){
if(req.method!=='POST')fail('NATIVE_METHOD',405);
if(!entry.result){entry.result=terminal(entry,'CANCELLED');try{await cancelServerJobProcess(entry.process,()=>cleanup(entry),{graceMs:500});}catch(error){entry.result.error=`NATIVE_CANCEL_CLEANUP_FAILED: ${String(error.message??error)}`;entry.result.errorCode='NATIVE_CANCEL_CLEANUP_FAILED';}}
if(action==='release'){cleanup(entry);clearTimeout(entry.expiry);jobs.delete(id);}
json(res,200,entry.result);return true;
}
if(req.method!=='GET')fail('NATIVE_METHOD',405);
if(action==='artifact'){
const artifact=entry.result?.status==='SUCCEEDED'&&entry.result.artifacts.find(a=>a.id===url.searchParams.get('artifact'));
if(!artifact||!entry.workspace)fail('NATIVE_ARTIFACT_NOT_FOUND',404);
const file=path.join(entry.workspace,artifact.id);
res.setHeader('Content-Type',artifact.mime);res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',artifact.bytes);res.setHeader('X-Content-SHA256',artifact.sha256);
res.setHeader('Content-Disposition',`attachment; filename="${artifact.id}"`);
fs.createReadStream(file).on('error',()=>res.destroy()).pipe(res);return true;
}
if(action!=='read')fail('NATIVE_ACTION_INVALID');
const result=entry.result??terminal(entry,'RUNNING');
if(!entry.result&&entry.workspace){try{const file=path.join(entry.workspace,'progress.json');if(fs.statSync(file).size<4096)result.progress=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}}
json(res,200,result);return true;
}catch(error){if(error.statusCode===405)res.setHeader('Allow',action==='read'||action==='artifact'?'GET':'POST');if(error.statusCode===429)res.setHeader('Retry-After','1');json(res,error.statusCode??(error instanceof SyntaxError?400:500),{status:'FAILED',error:String(error.message??error),...(typeof error.errorCode==='string'?{errorCode:error.errorCode}:{})});return true;}
}