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,212 @@
import { NATIVE_AUDIO_CHANNELS, NATIVE_AUDIO_FRAMES, NATIVE_AUDIO_RATE, NATIVE_AUDIO_BUFFER_BLOCKS, NATIVE_AUDIO_RECOVERY_BLOCKS, NATIVE_AUDIO_BUSY_BLOCKS, type NativeAudioOutput as OutputMessage, type NativeAudioPull, type NativeAudioClock, type NativeAudioSegment, type NativeAudioPrepared } from "../../../protocol/native-audio";
/** Browser device only: no scene, playback, decoding or mixing emulation. */
export class NativeAudioOutput {
private context: AudioContext | null = null;
private generation = 0;
private sequence = 0;
private pending: number | null = null;
private preparation: { sequence: number; expiresAtMs: number } | null = null;
private bufferBlocks = NATIVE_AUDIO_BUFFER_BLOCKS;
private nextTime = 0;
private disposed = false;
private failed = false;
private segments: NativeAudioSegment[] = [];
private scheduledFrames = 0;
private completedFrames = 0;
private outputTime = 0;
private clockSequence = 0;
private pendingClock: number | null = null;
private nodes = new Set<AudioBufferSourceNode>();
private timer: ReturnType<typeof setInterval>;
constructor(private port: MessagePort, private onError: (message: string) => void) {
port.onmessage = (event: MessageEvent<OutputMessage>) => this.receive(event.data);
// resume() must execute within the same trusted gesture as Blender's input.
window.addEventListener("pointerdown", this.unlock, true);
window.addEventListener("keydown", this.unlock, true);
this.timer = setInterval(() => { this.publishClock(); this.pull(); }, 10);
}
private unlock = (): void => {
if (this.disposed) return;
try {
if (!this.context) {
this.context = new AudioContext({ latencyHint: "interactive" });
this.context.onstatechange = () => { this.publishClock(); this.pull(); };
}
if (this.context.state !== "running") {
void this.context.resume().then(() => this.pull()).catch(error => this.onError(String(error)));
}
}
catch (error) { this.onError(String(error)); }
};
private flush(resetClock = true): void {
// Freeze the last presented position before removing queued nodes on error
// or shutdown. Only a native generation reset may rewind the PCM counters.
if (!resetClock) this.publishClock(false);
for (const node of this.nodes) {
node.onended = null;
node.stop();
node.disconnect();
}
this.nodes.clear();
this.nextTime = 0;
if (resetClock) {
this.preparation = null;
this.bufferBlocks = NATIVE_AUDIO_BUFFER_BLOCKS;
this.segments = [];
this.scheduledFrames = 0;
this.completedFrames = 0;
this.outputTime = 0;
this.pendingClock = null;
}
}
private publishClock(allowAdvance = true, force = false): void {
if (this.disposed) return;
const context = this.context;
const now = performance.now();
const running = !!context && context.state === "running" && !this.failed && allowAdvance;
if (context && !this.failed) {
const stamp = context.getOutputTimestamp?.();
// getOutputTimestamp already accounts for physical output delay; do not
// subtract baseLatency again. Older devices use the explicit latency sum.
const time = stamp && typeof stamp.performanceTime === 'number' && typeof stamp.contextTime === 'number' && stamp.performanceTime > 0 && Number.isFinite(stamp.contextTime)
? stamp.contextTime + (running ? Math.max(0, now - stamp.performanceTime) / 1000 : 0)
: context.currentTime - (context.baseLatency || 0) - (context.outputLatency || 0);
this.outputTime = Math.max(this.outputTime, Math.min(context.currentTime, time));
}
// Retire by device presentation, not AudioBufferSourceNode.onended: that
// event describes graph rendering and can precede the actual speaker output.
while (this.segments.length && this.outputTime >=
this.segments[0].start + this.segments[0].frames / NATIVE_AUDIO_RATE) {
const segment = this.segments.shift()!;
this.completedFrames = segment.firstFrame + segment.frames;
}
// Coalesce normal feedback while native code owns the Worker synchronously.
// A final freeze bypasses this slot so shutdown cannot leave a running clock.
if (allowAdvance && !force && this.pendingClock !== null) return;
this.pendingClock = ++this.clockSequence;
this.port.postMessage({
type: "clock", generation: this.generation,
sequence: this.pendingClock,
sampledAtMs: performance.timeOrigin + now, outputTime: this.outputTime,
running, completedFrames: this.completedFrames, segments: this.segments,
} satisfies NativeAudioClock);
}
private finishPreparation(allowAdvance = true): void {
if (!this.preparation) return;
const sequence = this.preparation.sequence;
this.preparation = null;
// Same port ordering makes this scheduled device clock visible to the
// Worker before it resumes the native command after the acknowledgement.
this.publishClock(allowAdvance, true);
this.port.postMessage({ type: "prepared", generation: this.generation, sequence } satisfies NativeAudioPrepared);
}
private pull(): void {
const context = this.context;
if (this.preparation && (this.failed || !context || context.state !== "running" ||
performance.timeOrigin + performance.now() >= this.preparation.expiresAtMs)) {
this.finishPreparation();
}
if (this.disposed || this.failed || !context || context.state !== "running" || this.pending !== null) return;
const blockTime = NATIVE_AUDIO_FRAMES / NATIVE_AUDIO_RATE;
const queued = Math.max(0, this.nextTime - context.currentTime);
const target = (this.preparation ? NATIVE_AUDIO_BUSY_BLOCKS : this.bufferBlocks) * blockTime;
if (this.preparation && queued >= target - blockTime) {
this.finishPreparation();
return;
}
// Refill below the low-water mark in one bounded native batch. During a
// command preparation fill the entire reserve, then acknowledge scheduling.
if (!this.preparation && queued > target / 2) return;
const blocks = Math.min(NATIVE_AUDIO_BUSY_BLOCKS, Math.max(1, Math.ceil((target - queued) / blockTime)));
this.pending = ++this.sequence;
this.port.postMessage({ type: "pull", generation: this.generation, sequence: this.pending, blocks } satisfies NativeAudioPull);
}
private receive(message: OutputMessage): void {
if (this.disposed) return;
if (message.type === "reset") {
// An old pull can receive the same reset after the native notification.
// Do not discard a newer in-flight block on that duplicate acknowledgement.
if (message.generation <= this.generation) return;
this.generation = message.generation;
this.pending = null;
this.failed = false;
this.flush();
this.publishClock();
return;
}
if (message.generation !== this.generation) return;
if (message.type === "prepare") {
this.preparation = { sequence: message.sequence, expiresAtMs: message.expiresAtMs };
this.pull();
return;
}
if (message.type === "clockAck") {
if (message.sequence === this.pendingClock) this.pendingClock = null;
return;
}
if (message.type === "error") {
this.pending = null;
this.flush(false);
this.failed = true;
this.finishPreparation();
this.onError(message.message);
return;
}
if (message.sequence !== this.pending) return;
this.pending = null;
const context = this.context;
if (!message.samples || !context) { this.finishPreparation(); return; }
try {
const samples = new Float32Array(message.samples);
const frames = samples.length / NATIVE_AUDIO_CHANNELS;
if (frames < NATIVE_AUDIO_FRAMES || frames > NATIVE_AUDIO_FRAMES * NATIVE_AUDIO_BUSY_BLOCKS ||
frames % NATIVE_AUDIO_FRAMES !== 0) throw new Error("Invalid native audio block");
const buffer = context.createBuffer(NATIVE_AUDIO_CHANNELS, frames, NATIVE_AUDIO_RATE);
for (let channel = 0; channel < NATIVE_AUDIO_CHANNELS; channel++) {
const output = buffer.getChannelData(channel);
for (let frame = 0; frame < frames; frame++) output[frame] = samples[frame * NATIVE_AUDIO_CHANNELS + channel];
}
const node = context.createBufferSource();
node.buffer = buffer;
node.connect(context.destination);
node.onended = () => { this.nodes.delete(node); node.disconnect(); };
this.nodes.add(node);
if (this.nextTime > 0 && this.nextTime <= context.currentTime) {
// Never replay missed PCM or pretend the native timeline advanced during
// silence. Start a new device-clock segment and increase bounded reserve.
this.bufferBlocks = Math.min(NATIVE_AUDIO_RECOVERY_BLOCKS, this.bufferBlocks + 2);
}
this.nextTime = Math.max(this.nextTime, context.currentTime + 0.005);
node.start(this.nextTime);
this.segments.push({ start: this.nextTime, frames, firstFrame: this.scheduledFrames });
this.scheduledFrames += frames;
this.nextTime += buffer.duration;
this.publishClock();
this.pull();
}
catch (error) { this.flush(false); this.failed = true; this.finishPreparation(); this.onError(String(error)); }
}
dispose(): void {
this.flush(false);
this.finishPreparation(false);
this.disposed = true;
clearInterval(this.timer);
window.removeEventListener("pointerdown", this.unlock, true);
window.removeEventListener("keydown", this.unlock, true);
this.port.close();
if (this.context) {
this.context.onstatechange = null;
void this.context.close().catch(() => {});
}
this.context = null;
}
}

View File

@@ -0,0 +1,142 @@
import type {NativeWorkflowInput} from '../../../protocol/native-workflow';
import {sha256Hex} from '../../../protocol/render-assets';
type Binding=NonNullable<NativeWorkflowInput['resources']>;
type Stored={archive:Blob;sha256:string};
const endpoint='/api/blender-resources';
async function stored(project:string,value?:Stored|null):Promise<Stored|undefined>{
const db=await new Promise<IDBDatabase>((resolve,reject)=>{
const request=indexedDB.open('blender-native-resources',1);
request.onupgradeneeded=()=>request.result.createObjectStore('projects');
request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);
});
try{return await new Promise((resolve,reject)=>{
const transaction=db.transaction('projects',value===undefined?'readonly':'readwrite');
const store=transaction.objectStore('projects');
const request=value===undefined?store.get(project):value===null?store.delete(project):store.put(value,project);
transaction.oncomplete=()=>resolve(value===undefined?request.result:undefined);
transaction.onabort=()=>reject(transaction.error??new Error('RESOURCE_STORAGE_ABORTED'));
transaction.onerror=()=>reject(transaction.error);
});}finally{db.close();}
}
// Store the actual bytes, not a short-lived service token. Each project has its own archive.
export class NativeResourceClient {
static removeStoredProject(project:string):Promise<Stored|undefined>{return stored(project,null);}
private project:string|null=null;
private generation=0;
private binding:Binding|undefined;
private archive:Stored|undefined;
private loaded=false;
private pending:Promise<Binding|undefined>|undefined;
constructor(){
window.addEventListener('pagehide',()=>{
const binding=this.binding;this.binding=undefined;
if(binding)void fetch(endpoint,{method:'POST',keepalive:true,headers:{'X-Blender-Workflow':'1',
'X-Blender-Resource-Action':'release','Content-Type':'application/json'},body:JSON.stringify(binding)}).catch(()=>undefined);
});
}
setProject(project:string|null):void{
if(project===this.project)return;
const old=this.binding;this.project=project;this.generation++;this.binding=undefined;
this.archive=undefined;this.loaded=false;this.pending=undefined;
if(old)void this.release(old);
}
async copyToProject(project:string):Promise<void>{
if(!project||project===this.project)throw new Error('RESOURCE_COPY_TARGET_INVALID');
const generation=this.generation;
await this.resolve();this.assertCurrent(generation);
if(this.archive)await stored(project,this.archive);
this.assertCurrent(generation);
}
private assertCurrent(generation:number):void{if(generation!==this.generation)throw new Error('RESOURCE_PROJECT_CHANGED');}
private async request(action:string,body:Blob|Binding):Promise<Response>{
return fetch(endpoint,{method:'POST',headers:{'X-Blender-Workflow':'1','X-Blender-Resource-Action':action,
'Content-Type':body instanceof Blob?'application/octet-stream':'application/json'},
body:body instanceof Blob?body:JSON.stringify(body),signal:AbortSignal.timeout(120000)});
}
private async release(binding:Binding):Promise<void>{await this.request('release',binding).catch(()=>undefined);}
private async upload(archive:Blob):Promise<Binding&{files:unknown[]}>{
const response=await this.request('upload',archive),result=await response.json();
if(!response.ok)throw new Error(result.error??'RESOURCE_UPLOAD_FAILED');
if(!/^[a-f0-9]{64}$/.test(result.token)||!/^[a-f0-9]{64}$/.test(result.sha256))throw new Error('RESOURCE_RESULT_BINDING');
return result;
}
private async archiveFor(binding:Binding):Promise<Stored>{
const response=await this.request('export',binding);
if(!response.ok){const error=await response.json();throw new Error(error.error??'RESOURCE_EXPORT_FAILED');}
const archive=await response.blob();
if(archive.size>258*1024*1024)throw new Error('RESOURCE_ARCHIVE_BUDGET');
const data=await archive.arrayBuffer(),length=new DataView(data).getUint32(4,true);
if(new TextDecoder().decode(data.slice(0,4))!=='BAR1'||8+length>data.byteLength||
await sha256Hex(data.slice(8,8+length))!==binding.sha256)throw new Error('RESOURCE_ARCHIVE_BINDING');
return {archive,sha256:binding.sha256};
}
async adopt(binding:Binding|undefined):Promise<void>{
if(!binding)return;
const generation=this.generation,project=this.project;
const archive=await this.archiveFor(binding);this.assertCurrent(generation);
if(project)await stored(project,archive);this.assertCurrent(generation);
const previous=this.binding;this.binding=binding;this.archive=archive;this.loaded=true;
if(previous&&previous.token!==binding.token)void this.release(previous);
}
async sync(archive:Blob,merge=false):Promise<{files:unknown[]}>{
const generation=this.generation;
if(merge){
await this.resolve();this.assertCurrent(generation);
if(this.archive){
const entries=new Map<string,{metadata:{path:string;bytes:number;sha256:string;sourcePath?:string};bytes:Blob}>();
for(const source of [this.archive.archive,archive]){
const head=await source.slice(0,8).arrayBuffer();
if(new TextDecoder().decode(head.slice(0,4))!=='BAR1')throw new Error('RESOURCE_ENVELOPE_INVALID');
const length=new DataView(head).getUint32(4,true),manifest=JSON.parse(await source.slice(8,8+length).text());let offset=8+length;
for(const metadata of manifest.files){
if(!Number.isSafeInteger(metadata.bytes)||metadata.bytes<0||offset+metadata.bytes>source.size)throw new Error('RESOURCE_MANIFEST_INVALID');
const bytes=source.slice(offset,offset+metadata.bytes);offset+=metadata.bytes;
if(await sha256Hex(await bytes.arrayBuffer())!==metadata.sha256)throw new Error('RESOURCE_HASH_MISMATCH');
entries.set(metadata.path,{metadata,bytes});
}
if(offset!==source.size)throw new Error('RESOURCE_TRAILING_DATA');
}
const values=[...entries.values()];
if(values.length>4096||values.reduce((total,item)=>total+item.bytes.size,0)>256*1024*1024)throw new Error('RESOURCE_UPLOAD_BUDGET');
const metadata=new TextEncoder().encode(JSON.stringify({files:values.map(item=>item.metadata)})),header=new Uint8Array(8);
header.set(new TextEncoder().encode('BAR1'));new DataView(header.buffer).setUint32(4,metadata.length,true);
archive=new Blob([header,metadata,...values.map(item=>item.bytes)]);
}
}
this.assertCurrent(generation);const result=await this.upload(archive);
try{this.assertCurrent(generation);await this.adopt(result);return result;}
catch(error){await this.release(result);throw error;}
}
async resolve():Promise<Binding|undefined>{
if(this.pending)return this.pending;
const generation=this.generation;
const pending=this.restore(generation);this.pending=pending;
try{return await pending;}finally{if(this.pending===pending)this.pending=undefined;}
}
private async restore(generation:number):Promise<Binding|undefined>{
if(!this.loaded){
const archive=this.project?await stored(this.project):undefined;this.assertCurrent(generation);
this.archive=archive;this.loaded=true;
}
if(!this.archive)return undefined;
if(this.binding){
const response=await this.request('describe',this.binding);this.assertCurrent(generation);
if(response.ok){await response.json();return this.binding;}
const result=await response.json();
if(result.error!=='RESOURCE_BUNDLE_EXPIRED_OR_CHANGED')throw new Error(result.error??'RESOURCE_RESTORE_FAILED');
}
const result=await this.upload(this.archive.archive);
try{this.assertCurrent(generation);if(result.sha256!==this.archive.sha256)throw new Error('RESOURCE_RESTORE_HASH');}
catch(error){await this.release(result);throw error;}
this.binding={token:result.token,sha256:result.sha256};return this.binding;
}
async clear():Promise<void>{
const generation=this.generation,project=this.project,old=this.binding;
if(project)await stored(project,null);this.assertCurrent(generation);
this.generation++;this.binding=undefined;this.archive=undefined;this.loaded=true;this.pending=undefined;
if(old)await this.release(old);
}
}

View File

@@ -0,0 +1,136 @@
import {canonicalNativeWorkflow,encodeNativeWorkflow,type NativeWorkflowContext,type NativeWorkflowInput,type NativeSessionHandle,type NativeSessionCommand,type NativeDesktopEvent,type NativeWorkflowArtifact,type NativeMainReceipt} from '../../../protocol/native-workflow';
import type {WebEngineClient} from './WebEngineClient';
import {sha256Hex} from '../../../protocol/render-assets';
type NativeSessionReply = { status?: string; error?: string; sequence?: number; inputCapabilities?: { pen: boolean; logicalKeys: boolean; horizontalWheel: boolean }; command?: { sequence: number; status: string; error?: string; output?: string; artifacts?: NativeWorkflowArtifact[]; resources?: NativeWorkflowInput['resources']; mainReceipt?: NativeMainReceipt } };
// Each handle owns one native Main and one Python namespace; outside edits invalidate it.
export class NativeSessionClient {
handle: NativeSessionHandle | null=null;
busy=false;
private generation=0;
private request:AbortController|null=null;
private inputTail:Promise<void>=Promise.resolve();
private frameTag='';
private lastEventAt=0;
cursorEmbedded=false;
inputCapabilities:{pen:boolean;logicalKeys:boolean;horizontalWheel:boolean}={pen:false,logicalKeys:false,horizontalWheel:false};
private readonly onPageHide=()=>{void this.close();};
private static endpoint='/api/blender-session';
constructor(private readonly engine?:WebEngineClient){
window.addEventListener('pagehide',this.onPageHide);
}
private async call<T = NativeSessionReply>(action:string,body?:unknown,signal?:AbortSignal):Promise<T>{
const session=this.handle;if(!session)throw new Error('SESSION_NOT_CONNECTED');
const response=await fetch(body===undefined?`${NativeSessionClient.endpoint}?id=${encodeURIComponent(session.id)}`:NativeSessionClient.endpoint,
{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json','X-Blender-Workflow':'1','X-Blender-Session-Action':action,'X-Blender-Session-Token':session.token},
...(body===undefined?{}:{body:JSON.stringify({...body as object,id:session.id,token:session.token})}),signal:signal??AbortSignal.timeout(15000)});
const result=await response.json();if(!response.ok)throw new Error(result.error??'SESSION_HTTP_FAILED');return result;
}
async start(context:NativeWorkflowContext,resources?:NativeWorkflowInput['resources'],raw?:{source:ArrayBuffer;desktop:NonNullable<NativeWorkflowInput['desktop']>}):Promise<void>{
this.engine?.assertNativeServices();
await this.close();const generation=this.generation;this.busy=true;
const controller=new AbortController();this.request=controller;const timer=setTimeout(()=>controller.abort(),120000);
try{
let source=raw?.source,revision=0;
if(!source){
if(!this.engine)throw new Error('SESSION_SOURCE_REQUIRED');
const before=await this.engine.snapshot();source=await this.engine.saveBlend();const after=await this.engine.snapshot();
if(before.snapshot.revision!==after.snapshot.revision)throw new Error('SESSION_SOURCE_CHANGED');revision=after.snapshot.revision;
}
const unsigned={schemaVersion:1 as const,kind:'PYTHON' as const,context,resources,desktop:raw?.desktop,python:{source:'',textName:'WebConsole.py',console:true},
jobId:`session-${crypto.randomUUID()}`,sourceRevision:revision,sourceBlendSha256:await sha256Hex(source),sourceBlendByteLength:source.byteLength,timeoutMs:600000};
const request={...unsigned,requestSha256:await sha256Hex(new TextEncoder().encode(canonicalNativeWorkflow(unsigned)).buffer)};
const response=await fetch(NativeSessionClient.endpoint,{method:'POST',headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'start','Content-Type':'application/octet-stream'},body:encodeNativeWorkflow(request,source),signal:controller.signal});
const value=await response.json();if(!response.ok)throw new Error(value.error??'SESSION_START_FAILED');
if(generation!==this.generation){await fetch(NativeSessionClient.endpoint,{method:'POST',headers:{'Content-Type':'application/json','X-Blender-Workflow':'1','X-Blender-Session-Action':'close'},body:JSON.stringify(value)});throw new Error('SESSION_CANCELLED');}
this.handle=value;
if(raw&&(value.desktopProtocolVersion!==2||value.width!==raw.desktop.width||value.height!==raw.desktop.height))throw new Error('DESKTOP_SERVICE_RESTART_REQUIRED');
while(true){const state=await this.call('read',undefined,controller.signal);if(state.status==='READY'){this.inputCapabilities=state.inputCapabilities??this.inputCapabilities;break;}if(state.status==='FAILED')throw new Error(state.error);await new Promise(r=>setTimeout(r,200));}
}catch(error){await this.close();throw error;}
finally{clearTimeout(timer);this.busy=false;if(this.request===controller)this.request=null;}
}
async assertCurrent():Promise<void>{
if(!this.handle)throw new Error('SESSION_NOT_CONNECTED');
if(!this.engine)return;
const snapshot=await this.engine.snapshot();
if(snapshot.snapshot.revision!==this.handle.sourceRevision||await sha256Hex(await this.engine.saveBlend())!==this.handle.sourceBlendSha256)throw new Error('SESSION_SOURCE_CHANGED_RESET_REQUIRED');
}
async execute(command:NativeSessionCommand):Promise<{output:string;pythonError?:string;resources?:NativeWorkflowInput['resources'];mainReceipt?:NativeMainReceipt;scene?:Awaited<ReturnType<WebEngineClient['applyServerBlendResult']>>;files:{artifact:NativeWorkflowArtifact;bytes:ArrayBuffer}[]}>{
if(this.busy)throw new Error('SESSION_BUSY');this.busy=true;
const controller=new AbortController();this.request=controller;const timer=setTimeout(()=>controller.abort(),125000);
try{
await this.inputTail;await this.assertCurrent();const handle=this.handle!;
if(!this.engine){
const state=await this.call('read',undefined,controller.signal);
if(state.command?.status==='RUNNING')throw new Error('SESSION_PREVIOUS_COMMAND_RUNNING');
if(state.command)await this.call('ack',{sequence:state.command.sequence},controller.signal);
}
const issued=await this.call('command',{command},controller.signal);
let result;
while(true){const state=await this.call('read',undefined,controller.signal);result=state.command;if(result&&result.sequence===issued.sequence&&result.status!=='RUNNING')break;await new Promise(r=>setTimeout(r,150));}
const pythonError=command.kind==='python'&&result.status==='PYTHON_ERROR';
if(result.status!=='SUCCEEDED'&&!pythonError){
const error=Object.assign(new Error((result.error??'SESSION_COMMAND_FAILED')+'\n'+(result.output??'')),{status:result.status,recoverable:!this.engine||result.status==='CANCELLED'||String(result.error??'').startsWith('SESSION_MODAL_ACTIVE_')});
if(error.recoverable)await this.call('ack',{sequence:issued.sequence},controller.signal);
throw error;
}
const files:{artifact:NativeWorkflowArtifact;bytes:ArrayBuffer}[]=[];let total=0;
for(const artifact of result.artifacts??[]){
if(!Number.isSafeInteger(artifact.bytes)||artifact.bytes<1||(total+=artifact.bytes)>512*1024*1024)throw new Error('SESSION_ARTIFACT_BUDGET');
const url=`${NativeSessionClient.endpoint}?id=${handle.id}&artifact=${encodeURIComponent(artifact.id)}`;
const response=await fetch(url,{headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'artifact','X-Blender-Session-Token':handle.token},signal:controller.signal});
if(!response.ok)throw new Error('SESSION_ARTIFACT_DOWNLOAD');const bytes=await response.arrayBuffer();
if(bytes.byteLength!==artifact.bytes||await sha256Hex(bytes)!==artifact.sha256)throw new Error('SESSION_ARTIFACT_HASH');files.push({artifact,bytes});
}
await this.assertCurrent();controller.signal.throwIfAborted();
const blend=files.find(f=>f.artifact.mime==='application/x-blender');let scene;
if(pythonError&&(!blend||typeof result.error!=='string'))throw new Error('SESSION_PYTHON_ERROR_RESULT_INVALID');
if(blend&&this.engine){
await this.engine.adoptNativeResources(result.resources);
await this.assertCurrent();controller.signal.throwIfAborted();
scene=await this.engine.applyServerBlendResult(blend.bytes.slice(0),handle.sourceRevision);
handle.sourceRevision=scene.snapshot.revision;handle.sourceBlendSha256=await sha256Hex(await this.engine.saveBlend());
}
// Scene commit has finished; losing a later ACK must not misreport that commit as failed.
if(scene){clearTimeout(timer);await this.call('ack',{sequence:issued.sequence}).catch(()=>this.close());}
else await this.call('ack',{sequence:issued.sequence},controller.signal);
return {output:result.output??'',...(pythonError?{pythonError:result.error}:{}),resources:result.resources,mainReceipt:result.mainReceipt,files,scene};
}catch(error){if(this.engine&&!(error as {recoverable?:boolean})?.recoverable)await this.close();throw error;}
finally{clearTimeout(timer);this.busy=false;if(this.request===controller)this.request=null;}
}
events(events:NativeDesktopEvent[]):Promise<void>{
if(!this.handle)return Promise.resolve();
const generation=this.generation;
this.inputTail=this.inputTail.catch(()=>undefined).then(async()=>{
const delay=10-(Date.now()-this.lastEventAt);if(delay>0)await new Promise(resolve=>setTimeout(resolve,delay));
if(generation===this.generation){await this.call('events',{events});this.lastEventAt=Date.now();}
});
return this.inputTail;
}
cancelModal():Promise<void>{return this.events([{type:'key',code:'Escape',down:true},{type:'key',code:'Escape',down:false}]);}
async upload(file:File,path:string):Promise<void>{
const handle=this.handle;if(!handle)throw new Error('SESSION_NOT_CONNECTED');
const response=await fetch(`${NativeSessionClient.endpoint}?id=${handle.id}`,{method:'POST',headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'upload','X-Blender-Session-Token':handle.token,'X-Blender-File-Path':encodeURIComponent(path),'Content-Type':'application/octet-stream'},body:file,signal:AbortSignal.timeout(120000)});
if(!response.ok)throw new Error((await response.json()).error??'SESSION_UPLOAD_FAILED');
}
async files():Promise<{files:{path:string;bytes:number;modified:number}[];links?:{id:string;url:string}[];network?:{mode:string;hosts:string[];lastError:string|null};preferenceError?:string;status?:{dirty:boolean;filepath:string;workspace:string;savedSequence:number;fileBrowser:boolean;error?:string}}> {
return this.call<Awaited<ReturnType<NativeSessionClient['files']>>>('files');
}
async acknowledgeLink(linkId:string):Promise<void>{await this.call('link-ack',{linkId});}
async download(path:string):Promise<Blob>{
const handle=this.handle;if(!handle)throw new Error('SESSION_NOT_CONNECTED');
const response=await fetch(`${NativeSessionClient.endpoint}?id=${handle.id}&file=${encodeURIComponent(path)}`,{headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'download','X-Blender-Session-Token':handle.token},signal:AbortSignal.timeout(120000)});
if(!response.ok)throw new Error((await response.json()).error??'SESSION_DOWNLOAD_FAILED');return response.blob();
}
async screen(signal?:AbortSignal):Promise<Blob|null>{
const handle=this.handle;if(!handle)return null;
const response=await fetch(`${NativeSessionClient.endpoint}?id=${handle.id}`,{headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'screen','X-Blender-Session-Token':handle.token,...(this.frameTag?{'If-None-Match':this.frameTag}:{})},signal:signal??AbortSignal.timeout(15000)});
if(response.status===204||response.status===304)return null;if(!response.ok)throw new Error('SESSION_SCREEN_UNAVAILABLE');this.frameTag=response.headers.get('ETag')??'';this.cursorEmbedded=response.headers.get('X-Blender-Cursor')==='embedded';return response.blob();
}
dispose():void{window.removeEventListener('pagehide',this.onPageHide);void this.close();}
async close():Promise<void>{
this.generation++;this.request?.abort();this.request=null;const handle=this.handle;this.handle=null;
this.inputTail=Promise.resolve();this.frameTag='';
if(handle)await fetch(NativeSessionClient.endpoint,{method:'POST',keepalive:true,headers:{'X-Blender-Workflow':'1','X-Blender-Session-Action':'close','Content-Type':'application/json'},body:JSON.stringify(handle),signal:AbortSignal.timeout(5000)}).catch(()=>undefined);
}
}

View File

@@ -0,0 +1,182 @@
import type {EmbeddedPythonResult} from './WebEngineClient';
import type {ProjectActionIdentity} from '../../../protocol/project-action-mutex';
export type PythonBridgeMethod = 'snapshot' | 'context' | 'command' | 'operators' | 'operator' | 'data_blocks' | 'properties';
export interface EmbeddedPythonHost {
run:(source:string,filename:string,console:boolean,interrupt?:SharedArrayBuffer)=>Promise<EmbeddedPythonResult>;
reset:(owner?:ProjectActionIdentity)=>Promise<void>;
}
export interface PythonSessionState {
status: 'idle' | 'running' | 'stopping' | 'error';
version: string;
output: string;
more: boolean;
backend:'WEBENGINE'|'PYODIDE';
}
export class PythonSessionClient {
private worker: Worker | null = null;
private sequence = 0;
private active: { id: number; resolve: (value: boolean) => void; timer: ReturnType<typeof setTimeout> } | null = null;
private chain: Promise<void> = Promise.resolve();
private listeners = new Set<() => void>();
private state: PythonSessionState = {status:'idle',version:'',output:'',more:false,backend:'PYODIDE'};
private disposed = false;
private embeddedPending:Promise<boolean>|null=null;
private interrupt:Int32Array|null=null;
private resetting=false;
private resetFailed=false;
private resetPromise:Promise<void>|null=null;
constructor(private readonly bridge: (method: PythonBridgeMethod, payload: Record<string, unknown>) => Promise<unknown>,private readonly embedded?:EmbeddedPythonHost) {
if(embedded)this.state.backend='WEBENGINE';
}
subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
getSnapshot = (): PythonSessionState => this.state;
private update(values: Partial<PythonSessionState>) { this.state = {...this.state,...values}; for (const listener of this.listeners) listener(); }
private append(text: string) { this.update({output:(this.state.output + text + '\n').slice(-524288)}); }
clearOutput = () => this.update({output:''});
private startWorker() {
if (this.worker) return this.worker;
const worker = new Worker(new URL('../workers/python.worker.ts', import.meta.url), {type:'module'});
this.worker = worker;
worker.onmessage = event => {
if (this.worker !== worker) return;
const message = event.data;
if (message.type === 'version') { this.update({version:message.version}); return; }
if (!this.active || message.runId !== this.active.id) return;
if (message.type === 'output') { this.append(String(message.text)); return; }
if (message.type === 'bridge') {
const runId = message.runId;
this.chain = this.chain.then(async () => {
if (this.worker !== worker || this.active?.id !== runId) return;
try {
if (!['snapshot','context','command','operators','operator','data_blocks','properties'].includes(message.method) || typeof message.payload !== 'string' || message.payload.length > 1048576) throw new Error('PYTHON_BRIDGE_INVALID');
const payload = JSON.parse(message.payload);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('PYTHON_BRIDGE_INVALID');
const result = await this.bridge(message.method, payload);
const serialized = JSON.stringify(result ?? null);
if (serialized.length > 16777216) throw new Error('PYTHON_BRIDGE_RESULT_TOO_LARGE');
if (this.worker === worker && this.active?.id === runId) worker.postMessage({type:'bridge-result',runId,id:message.id,payload:serialized});
} catch (error) {
if (this.worker === worker && this.active?.id === runId) worker.postMessage({type:'bridge-result',runId,id:message.id,error:String(error)});
}
}).catch(error => {
// A failed error reply must not leave every later bridge request rejected.
if (this.worker === worker) {
this.append(String(error));
// Do not await reset here: reset drains this same chain.
void this.reset(false);
}
});
return;
}
if (message.type === 'error') this.append(String(message.error));
if (message.type === 'done' || message.type === 'error') {
const active = this.active;
this.active = null; clearTimeout(active.timer);
// Await already dispatched mutations before allowing another run or project change.
this.update({status:'stopping',more:message.more === true});
void this.chain.then(() => {
const current = this.worker === worker && !this.resetting && !this.disposed;
if (current) this.update({status:message.type === 'error' ? 'error' : 'idle'});
active.resolve(current && message.type === 'done');
}, error => {
if (this.worker === worker) {
this.append(String(error));
this.update({status:'error',more:false});
}
active.resolve(false);
});
}
};
worker.onerror = event => { event.preventDefault(); this.append(event.message); void this.reset(false); };
worker.onmessageerror = () => { this.append('PYTHON_WORKER_MESSAGE_ERROR'); void this.reset(false); };
return worker;
}
run = async (source: string, filename = '<console>', console = true): Promise<boolean> => {
if (this.disposed || this.resetting || this.resetFailed || this.active || this.embeddedPending || this.state.status === 'stopping') {
/* A dropped submission must say why; a silent false leaves the console with
* no echo, no error and no request, which is indistinguishable from a hang. */
const blocked = this.disposed ? 'disposed' : this.resetting ? 'resetting' : this.resetFailed ? 'resetFailed' : this.active ? 'active' : this.embeddedPending ? 'embeddedPending' : 'stopping';
this.append(`PYTHON_SESSION_BLOCKED: ${blocked}`);
return false;
}
if (new TextEncoder().encode(source).length > 1048576 || source.includes('\0') || new TextEncoder().encode(filename).length > 256 || filename.includes('\0')) {
this.append('PYTHON_SOURCE_INVALID'); return false;
}
if(this.state.backend==='WEBENGINE'&&this.embedded){
this.append(console?`${this.state.more?'...':'>>>'} ${source}`:`[${filename}]`);
this.update({status:'running'});
this.interrupt=globalThis.crossOriginIsolated&&typeof SharedArrayBuffer!=='undefined'?new Int32Array(new SharedArrayBuffer(4)):null;
const host=this.embedded;
const interrupt=this.interrupt?.buffer as SharedArrayBuffer|undefined;
// Set pending before calling the host, including synchronous host failures.
this.embeddedPending=Promise.resolve().then(()=>{
if(this.disposed||this.resetting)throw new Error('PYTHON_EXECUTION_CANCELLED');
return host.run(source,filename,console,interrupt);
}).then(result=>{
if(result.output)this.append(result.output);
if(result.truncated)this.append('PYTHON_OUTPUT_LIMIT');
this.update({status:this.resetting?'stopping':result.state==='ERROR'?'error':'idle',version:result.version,more:result.state==='MORE'});
return result.state!=='ERROR';
}).catch(error=>{this.append(String(error));this.update({status:this.resetting?'stopping':'error'});return false;}).finally(()=>{this.embeddedPending=null;this.interrupt=null;});
return this.embeddedPending;
}
let worker: Worker;
try { worker = this.startWorker(); } catch (error) { this.append(String(error)); this.update({status:'error'}); return false; }
const id = ++this.sequence;
this.append(console ? `${this.state.more ? '...' : '>>>'} ${source}` : `[${filename}]`);
this.update({status:'running'});
return new Promise(resolve => {
const timer = setTimeout(() => { this.append('PYTHON_TIMEOUT: session reset'); void this.reset(false); }, 120000);
this.active = {id,resolve,timer};
try {
worker.postMessage({type:'run',runId:id,source,filename,console});
} catch (error) {
clearTimeout(timer);
this.active = null;
worker.terminate();
if (this.worker === worker) this.worker = null;
this.append(String(error));
this.update({status:'error',more:false});
resolve(false);
}
});
};
reset = (clear = true, owner?:ProjectActionIdentity): Promise<void> => {
if(this.resetPromise)return this.resetPromise;
this.resetPromise=this.resetSession(clear,owner).finally(()=>{this.resetPromise=null;});
return this.resetPromise;
};
private async resetSession(clear:boolean,owner?:ProjectActionIdentity):Promise<void> {
this.resetting=true;
this.resetFailed=false;
const active = this.active;
this.active = null;
if (active) { clearTimeout(active.timer); active.resolve(false); }
this.worker?.terminate(); this.worker = null;
this.update({status:'stopping',more:false,version:'',...(clear ? {output:''} : {})});
if(this.interrupt)Atomics.store(this.interrupt,0,1);
const failed = (error:unknown) => {
this.resetFailed=true;
if(!this.disposed)this.append(String(error));
};
try{
try { await this.embeddedPending; } catch(error) { failed(error); }
try { await this.chain; } catch(error) { failed(error); }
this.chain=Promise.resolve();
// Native cleanup must still run after a bridge failure.
try { await this.embedded?.reset(owner); } catch(error) { failed(error); }
}finally{
this.resetting=false;
this.update({status:this.resetFailed?'error':'idle',more:false,version:'',...(clear&&!this.resetFailed?{output:''}:{})});
}
}
stop = () => { this.append('PYTHON_STOP_REQUESTED: committed Blender commands retained'); return this.reset(false); };
setBackend=async(backend:'WEBENGINE'|'PYODIDE')=>{
if(this.disposed||this.resetting||this.state.status==='running'||this.state.status==='stopping'||backend===this.state.backend)return;
await this.reset();
if(!this.disposed&&!this.resetFailed)this.update({backend});
};
canInterrupt=()=>this.state.backend==='PYODIDE'||Boolean(this.interrupt);
dispose() { this.disposed = true; void this.reset(); this.listeners.clear(); }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import type {WebEngineClient} from './WebEngineClient';
import type {NativeWorkflowInput,NativeWorkflowArtifact} from '../../../protocol/native-workflow';
export async function runEmbeddedRender(client:WebEngineClient,input:NativeWorkflowInput,signal:AbortSignal) {
const settings=input.render;
if(input.kind!=='RENDER'||!settings)throw new Error('NATIVE_RENDER_SETTINGS_REQUIRED');
const {frameStart:start,frameEnd:end,step,format}=settings;
if(![start,end,step].every(Number.isSafeInteger)||start < -1000000||end>1000000||end<start||step<1||step>1000||Math.floor((end-start)/step)+1>240)throw new Error('NATIVE_RENDER_FRAME_RANGE_INVALID');
if(!['PNG','OPEN_EXR','FFMPEG'].includes(format))throw new Error('NATIVE_RENDER_FORMAT_INVALID');
signal.throwIfAborted();
const root=`/browser/exports/render-${crypto.randomUUID()}`,marker=`WEB_RENDER_${crypto.randomUUID()}:`;
const params={...settings,sceneName:input.context?.sceneName,root,marker};
const script=`import bpy, json, os
p = json.loads(${JSON.stringify(JSON.stringify(params))})
scene = bpy.data.scenes.get(p['sceneName']) if p.get('sceneName') else bpy.context.scene
if scene is None: raise RuntimeError('NATIVE_SCENE_CONTEXT_UNAVAILABLE')
r = scene.render
if p.get('engine') == 'CYCLES' or r.engine == 'CYCLES':
import sys, addon_utils
if '/python/scripts/addons_core' not in sys.path: sys.path.append('/python/scripts/addons_core')
if not hasattr(scene, 'cycles'):
if addon_utils.enable('cycles', default_set=True) is None or not hasattr(scene, 'cycles'): raise RuntimeError('CYCLES_REGISTRATION_FAILED')
old_render = {key: getattr(r, key) for key in ('engine', 'resolution_percentage', 'filepath', 'use_file_extension')}
old_frames = (scene.frame_start, scene.frame_end, scene.frame_step, scene.frame_current, scene.frame_subframe)
old_format = r.image_settings.file_format
old_cycles_device = scene.cycles.device if p.get('engine') == 'CYCLES' else None
old_video = (r.ffmpeg.format, r.ffmpeg.codec) if p['format'] == 'FFMPEG' else None
files = []
os.makedirs(p['root'], exist_ok=False)
try:
if p.get('engine', 'SCENE') != 'SCENE': r.engine = p['engine']
if old_cycles_device is not None: scene.cycles.device = 'CPU'
if p.get('resolutionPercentage') is not None: r.resolution_percentage = p['resolutionPercentage']
r.image_settings.file_format, r.use_file_extension = p['format'], True
if p['format'] == 'FFMPEG':
scene.frame_start, scene.frame_end, scene.frame_step = p['frameStart'], p['frameEnd'], p['step']
r.ffmpeg.format, r.ffmpeg.codec = 'MPEG4', 'H264'
r.filepath = p['root'] + '/animation.mp4'
if 'FINISHED' not in bpy.ops.render.render('EXEC_DEFAULT', animation=True, scene=scene.name): raise RuntimeError('NATIVE_RENDER_CANCELLED')
files = [{'path': p['root'] + '/' + name} for name in sorted(os.listdir(p['root'])) if name.endswith('.mp4')]
else:
for frame in range(p['frameStart'], p['frameEnd'] + 1, p['step']):
scene.frame_set(frame)
r.filepath = p['root'] + '/frame_' + str(frame).zfill(6)
if 'FINISHED' not in bpy.ops.render.render('EXEC_DEFAULT', write_still=True, scene=scene.name): raise RuntimeError('NATIVE_RENDER_CANCELLED')
files.append({'path': r.frame_path(frame=frame), 'frame': frame})
if sum(os.path.getsize(item['path']) for item in files) > 256 * 1024 * 1024: raise RuntimeError('NATIVE_RENDER_OUTPUT_BUDGET')
if not files or sum(os.path.getsize(item['path']) for item in files) > 256 * 1024 * 1024: raise RuntimeError('NATIVE_RENDER_OUTPUT_BUDGET')
finally:
if old_cycles_device is not None: scene.cycles.device = old_cycles_device
for key, value in old_render.items(): setattr(r, key, value)
r.image_settings.file_format = old_format
if old_video: r.ffmpeg.format, r.ffmpeg.codec = old_video
scene.frame_start, scene.frame_end, scene.frame_step = old_frames[:3]
scene.frame_set(old_frames[3], subframe=old_frames[4])
print(p['marker'] + json.dumps({'files': files, 'version': bpy.app.version_string}))
`;
const interrupt=typeof SharedArrayBuffer!=='undefined'&&globalThis.crossOriginIsolated?new SharedArrayBuffer(4):undefined;
const abort=()=>{if(interrupt)Atomics.store(new Int32Array(interrupt),0,1);};
signal.addEventListener('abort',abort,{once:true});
try {
signal.throwIfAborted();
const scene=await client.executeEmbeddedPython(`exec(${JSON.stringify(script)}, {})`,'BrowserRender.py',false,interrupt,signal,true);
signal.throwIfAborted();
if(scene.python.state!=='DONE'||scene.python.truncated)throw new Error(scene.python.output||'NATIVE_RENDER_EXECUTION_FAILED');
const line=scene.python.output.split(/\r?\n/).find(value=>value.startsWith(marker));
if(!line)throw new Error('NATIVE_RENDER_RESULT_MISSING');
const result=JSON.parse(line.slice(marker.length)) as {files:{path:string;frame?:number}[];version:string};
if(!Array.isArray(result.files)||!result.files.length||result.files.length>240)throw new Error('NATIVE_RENDER_OUTPUT_INVALID');
const files:{artifact:NativeWorkflowArtifact;bytes:ArrayBuffer}[]=[];
let total=0;
for(const file of result.files){
signal.throwIfAborted();
if(typeof file.path!=='string'||!file.path.startsWith(root+'/')||file.path.slice(root.length+1).includes('/'))throw new Error('NATIVE_RENDER_OUTPUT_PATH_INVALID');
const bytes=await client.downloadEditorResource(file.path);
signal.throwIfAborted();
if(!bytes.byteLength||(total+=bytes.byteLength)>256*1024*1024)throw new Error('NATIVE_RENDER_OUTPUT_BUDGET');
const sha256=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),value=>value.toString(16).padStart(2,'0')).join('');
const name=file.path.slice(root.length+1),mime=format==='PNG'?'image/png':format==='OPEN_EXR'?'image/x-exr':'video/mp4';
files.push({artifact:{id:file.path,name,mime,bytes:bytes.byteLength,sha256,...(file.frame===undefined?{}:{frame:file.frame})},bytes});
}
signal.throwIfAborted();
return {scene,files,job:{blenderVersion:result.version,output:scene.python.output.slice(0,scene.python.output.indexOf(marker))}};
} finally {
signal.removeEventListener('abort',abort);
}
}

View File

@@ -0,0 +1,131 @@
import { COMPUTE_LIMITS, COMPUTE_CAPABILITY_FIELDS, type ComputeCapabilities, type ComputeJob } from "../../../protocol/gpu-compute";
import { RemoteComputeEndpoint, type ComputeEndpoint } from "./gpu-compute-relay";
interface NativeComputeModule {
HEAPU8: Uint8Array;
HEAPU32: Uint32Array;
UTF8ToString(pointer: number): string;
webPythonInterrupt?: Int32Array | null;
webGPUComputeSync?: (metadata: number, buffers: number, count: number, timeout: number) => void;
webGPUComputeCapabilities?: () => ComputeCapabilities;
webGPUComputeRelease?: (owner: string) => void;
}
async function createReadyComputeWorker(signal: AbortSignal, port: MessagePort | null): Promise<ComputeEndpoint> {
if (signal.aborted) throw new Error("GPU compute host was disposed");
const worker: ComputeEndpoint = port ? new RemoteComputeEndpoint(port) : new Worker(new URL("../workers/gpu-compute.worker.ts", import.meta.url), { type: "module" });
try { await new Promise<void>((resolve, reject) => {
const finish = (error?: Error) => { clearTimeout(timer); worker.removeEventListener("message", ready); worker.removeEventListener("error", failed); signal.removeEventListener("abort", aborted); if (error) { worker.terminate(); reject(error); } else resolve(); };
const ready = (event: Event) => { if ((event as MessageEvent).data?.type === "gpu-compute-ready") finish(); }; const failed = (event: Event) => { event.preventDefault(); finish(new Error((event as ErrorEvent).message || "GPU compute worker startup failed")); }; const aborted = () => finish(new Error("GPU compute host was disposed"));
const timer = setTimeout(() => finish(new Error("GPU compute worker startup timed out")), 30_000); worker.addEventListener("message", ready); worker.addEventListener("error", failed); signal.addEventListener("abort", aborted, { once: true });
}); return worker; } catch (error) { worker.terminate(); throw error; }
}
/** Owns a separate GPU worker because native CPython dispatch is synchronous.
* Atomics.wait blocks only the engine worker; the UI and GPU event loop remain live. */
export async function installGPUComputeHost(module: NativeComputeModule, port: MessagePort | null = null): Promise<() => void> {
let worker: ComputeEndpoint | null = null;
let disposed = false;
const lifetime = new AbortController();
let preparing: Promise<void> | null = null;
const prepare = (): Promise<void> => preparing ??= createReadyComputeWorker(lifetime.signal, port).then(value => { if (disposed) { value.terminate(); throw new Error("GPU compute host was disposed"); } worker = value; }).finally(() => { preparing = null; });
await prepare();
const exchange = (request: object, data: Uint8Array, timeout: number): void => {
if (disposed) throw new Error("GPU compute host was disposed");
if (!globalThis.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") {
throw new Error("Native synchronous GPU compute requires cross-origin isolation");
}
const control = new Int32Array(new SharedArrayBuffer(8));
const error = new Uint8Array(new SharedArrayBuffer(COMPUTE_LIMITS.errorBytes));
if (!worker) { void prepare().catch(() => {}); throw new Error("GPU compute worker is not ready"); }
const abandon = () => {
Atomics.compareExchange(control, 0, 0, 2);
if (worker) { worker.terminate(); worker = null; void prepare().catch(() => {}); }
};
const deadline = performance.now() + timeout;
try {
worker.postMessage({ ...request, control: control.buffer, error: error.buffer, data: data.buffer });
for (;;) {
if (module.webPythonInterrupt && Atomics.load(module.webPythonInterrupt, 0)) {
abandon(); throw new Error("GPU compute cancelled");
}
const state = Atomics.load(control, 0);
if (state === 1) break;
if (state === -1) {
const length = Atomics.load(control, 1);
throw new Error(new TextDecoder().decode(error.slice(0, Math.min(error.length, Math.max(0, length)))));
}
if (performance.now() >= deadline) {
const length = Math.min(error.length, Math.max(0, Atomics.load(control, 1))), phase = new TextDecoder().decode(error.slice(0, length));
abandon(); throw new Error(`GPU compute deadline exceeded (${phase || 'queued or worker not entered'})`);
}
Atomics.wait(control, 0, 0, Math.min(50, deadline - performance.now()));
}
} catch (reason) {
abandon();
throw reason;
}
};
module.webGPUComputeCapabilities = () => {
if (!globalThis.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") {
throw new Error("Native synchronous GPU compute requires cross-origin isolation");
}
const data = new Uint8Array(new SharedArrayBuffer(COMPUTE_CAPABILITY_FIELDS.length * 4));
exchange({ capabilities: true }, data, 30000);
const words = new Uint32Array(data.buffer);
const capabilities = {} as ComputeCapabilities;
COMPUTE_CAPABILITY_FIELDS.forEach((field, index) => {
if (!words[index]) throw new Error(`Missing WebGPU device limit: ${field}`);
capabilities[field] = words[index];
});
return capabilities;
};
// Native unbind does not release ownership. Destruction/context teardown does.
// Posting on this worker preserves ordering with all dispatches, including cleanup.
module.webGPUComputeRelease = owner => {
if (!disposed && worker) {
try { worker.postMessage({ releaseOwners: [owner] }); }
catch { worker.terminate(); worker = null; }
}
};
module.webGPUComputeSync = (metadata, table, count, timeout) => {
if (disposed) throw new Error("GPU compute host was disposed");
if (!globalThis.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") {
throw new Error("Native synchronous GPU compute requires cross-origin isolation");
}
if (!Number.isInteger(count) || count < 1 || count > COMPUTE_LIMITS.buffers ||
table < 0 || table % 4 !== 0 || table + count * 8 > module.HEAPU8.length ||
!Number.isInteger(timeout) || timeout < 100 || timeout > 120000) {
throw new Error("Invalid native GPU compute buffer envelope");
}
const job = JSON.parse(module.UTF8ToString(metadata)) as ComputeJob;
const sizes: number[] = [], pointers: number[] = [];
let total = 0;
for (let i = 0; i < count; i++) {
const pointer = module.HEAPU32[table / 4 + i * 2];
const size = module.HEAPU32[table / 4 + i * 2 + 1];
if (size < 4 || size % 4 !== 0 || size > COMPUTE_LIMITS.bufferBytes ||
pointer + size > module.HEAPU8.length) throw new Error("Invalid GPU compute buffer range");
sizes.push(size); pointers.push(pointer); total += size;
}
if (total > COMPUTE_LIMITS.totalBytes) throw new Error("GPU compute job exceeds memory budget");
const data = new Uint8Array(new SharedArrayBuffer(total));
let offset = 0;
for (let i = 0; i < count; i++) {
data.set(module.HEAPU8.subarray(pointers[i], pointers[i] + sizes[i]), offset);
offset += sizes[i];
}
exchange({ job, sizes }, data, timeout);
// No native output changes before every GPU result and error scope is complete.
offset = 0;
for (let i = 0; i < count; i++) {
module.HEAPU8.set(data.subarray(offset, offset + sizes[i]), pointers[i]);
offset += sizes[i];
}
};
return () => {
disposed = true; lifetime.abort(); worker?.terminate(); worker = null;
delete module.webGPUComputeSync;
delete module.webGPUComputeCapabilities;
delete module.webGPUComputeRelease;
};
}

View File

@@ -0,0 +1,45 @@
import { COMPUTE_LIMITS } from '../../../protocol/gpu-compute';
export type ComputeEndpoint = EventTarget & { postMessage(message: unknown): void; terminate(): void };
export class RemoteComputeEndpoint extends EventTarget implements ComputeEndpoint {
private readonly id = crypto.randomUUID();
private closed = false;
private readonly receive = (event: MessageEvent) => {
const data = event.data; if(this.closed || data?.id !== this.id)return;
if(data.type === 'ready')this.dispatchEvent(new MessageEvent('message', { data: { type: 'gpu-compute-ready' } })); else if(data.type === 'error')this.dispatchEvent(new ErrorEvent('error', { message: String(data.message) }));
};
constructor(private readonly port: MessagePort) { super(); port.addEventListener('message', this.receive); port.start(); port.postMessage({ type: 'prepare', id: this.id }); }
postMessage(message: unknown): void { if(this.closed)throw new Error('GPU compute endpoint closed'); this.port.postMessage({ type: 'dispatch', id: this.id, request: message }); }
terminate(): void { if(this.closed)return; this.closed = true; this.port.postMessage({ type: 'close', id: this.id }); this.port.removeEventListener('message', this.receive); }
}
type PendingCompute = { control: SharedArrayBuffer; error: SharedArrayBuffer };
type ComputeEntry = { worker: Worker; ready: boolean; pending: Set<PendingCompute> };
export function attachPageComputeRelay(engine: Worker): void {
const { port1, port2 } = new MessageChannel(), entries = new Map<string, ComputeEntry>(); let closed = false;
const fail = (pending: PendingCompute, message: string) => {
const control = new Int32Array(pending.control), error = new Uint8Array(pending.error); if(Atomics.load(control, 0) !== 0)return;
const bytes = new TextEncoder().encode(message).subarray(0, error.length); error.set(bytes); Atomics.store(control, 1, bytes.length); Atomics.compareExchange(control, 0, 0, -1); Atomics.notify(control, 0);
};
const close = (id: string, message: string) => { const entry = entries.get(id); if(!entry)return; entries.delete(id); for(const pending of entry.pending)fail(pending, message); entry.worker.terminate(); };
port1.onmessage = ({ data }) => {
if(closed || typeof data?.id !== 'string')return; const id = data.id;
if(data.type === 'prepare'){
if(entries.has(id)){port1.postMessage({ type: 'error', id, message: 'GPU compute endpoint already exists' }); return;}
try {
const worker = new Worker(new URL('../workers/gpu-compute.worker.ts', import.meta.url), { type: 'module' }), entry: ComputeEntry = { worker, ready: false, pending: new Set() }; entries.set(id, entry);
worker.onmessage = event => { if(closed || entries.get(id) !== entry)return; if(event.data?.type === 'gpu-compute-ready'){entry.ready = true; port1.postMessage({ type: 'ready', id });} };
worker.onerror = event => { event.preventDefault(); if(entries.get(id) !== entry)return; const message = event.message || 'GPU compute worker failed'; close(id, message); port1.postMessage({ type: 'error', id, message }); };
} catch(error) { close(id, String(error)); port1.postMessage({ type: 'error', id, message: String(error) }); }
} else if(data.type === 'close'){close(id, 'GPU compute endpoint closed');
} else if(data.type === 'dispatch'){
const entry = entries.get(id), request = data.request;
const pending: PendingCompute | null = request?.control instanceof SharedArrayBuffer && request.control.byteLength === 8 && request.error instanceof SharedArrayBuffer && request.error.byteLength === COMPUTE_LIMITS.errorBytes ? request : null;
if(!entry?.ready){if(pending)fail(pending, 'GPU compute relay not ready'); return;}
for(const item of entry.pending)if(Atomics.load(new Int32Array(item.control), 0) !== 0)entry.pending.delete(item); if(pending)entry.pending.add(pending);
try{entry.worker.postMessage(request);}catch(error){close(id, String(error)); port1.postMessage({ type: 'error', id, message: String(error) });}
}
};
const terminate = engine.terminate.bind(engine);
engine.terminate = () => { if(!closed){closed = true; for(const id of [...entries.keys()])close(id, 'Engine compute relay disposed'); port1.close();} terminate(); };
port1.start();
try{engine.postMessage({ type: 'native-gpu-compute-port', port: port2 }, [port2]);}catch(error){engine.terminate(); throw error;}
}

View File

@@ -0,0 +1,161 @@
import { NATIVE_AUDIO_CHANNELS, NATIVE_AUDIO_FRAMES, NATIVE_AUDIO_RATE, NATIVE_AUDIO_BUSY_BLOCKS, type NativeAudioInput, type NativeAudioClock, type NativeAudioPull, type NativeAudioOutput } from "../../../protocol/native-audio";
export interface NativeAudioModule {
_malloc(size: number): number;
_free(pointer: number): void;
HEAPU8: Uint8Array;
_web_engine_audio_read?: (pointer: number, frames: number) => number;
webAudioReset?: () => void;
webAudioOutputDelay?: () => number;
}
/** Worker-side pull adapter. Only the committed Main's module can be audible. */
export class NativeAudioTransport {
private target: NativeAudioModule | null = null;
private pointer = 0;
private generation = 0;
private failed = false;
private mixedFrames = 0;
private presentedFrames = 0;
private clock: NativeAudioClock | null = null;
private prepareSequence = 0;
private preparation: { sequence: number; finish: () => void } | null = null;
readonly port: MessagePort;
constructor() {
const channel = new MessageChannel();
this.port = channel.port1;
this.output = channel.port2;
this.output.onmessage = (event: MessageEvent<NativeAudioInput>) => {
const message = event.data;
if (message.type === "clock") {
if (message.generation === this.generation) this.clock = message;
this.send({ type: "clockAck", generation: message.generation, sequence: message.sequence });
}
else if (message.type === "prepared") {
if (message.generation === this.generation && message.sequence === this.preparation?.sequence) {
this.preparation.finish();
}
}
else this.pull(message);
};
}
private readonly output: MessagePort;
/** Called only at the serialized request boundary, before entering CPython or
* a native operator. Never re-enter Audaspace from a Python trace/RNA callback:
* that callback may already own native device or dependency-graph locks.
* Yield this Worker so the Window can schedule the reserve and acknowledge it.
* A missing/suspended output must not block a user's native command. */
async prepareForSynchronousCall(): Promise<void> {
if (!this.target || this.failed || !this.clock?.running || !this.mixedFrames) return;
this.preparation?.finish();
await new Promise<void>(resolve => {
const sequence = ++this.prepareSequence;
const finish = () => {
if (this.preparation?.sequence !== sequence) return;
clearTimeout(timer);
this.preparation = null;
resolve();
};
const timer = setTimeout(finish, 150);
this.preparation = { sequence, finish };
this.send({ type: "prepare", generation: this.generation, sequence,
expiresAtMs: performance.timeOrigin + performance.now() + 150 });
});
}
install(target: NativeAudioModule): void {
target.webAudioReset = () => { if (this.target === target) this.reset(); };
target.webAudioOutputDelay = () => this.target === target ? this.outputDelay() : NaN;
}
select(target: NativeAudioModule | null): void {
if (this.target === target) return;
if (this.pointer && this.target) this.target._free(this.pointer);
this.pointer = 0;
this.target = target;
this.reset();
}
release(target: NativeAudioModule): void {
if (this.target === target) this.select(null);
delete target.webAudioReset;
delete target.webAudioOutputDelay;
}
private send(message: NativeAudioOutput, transfer: Transferable[] = []): void {
this.output.postMessage(message, transfer);
}
private reset(): void {
this.preparation?.finish();
this.failed = false;
this.mixedFrames = 0;
this.presentedFrames = 0;
this.clock = null;
this.send({ type: "reset", generation: ++this.generation });
}
private outputDelay(): number {
const clock = this.clock;
if (clock) {
// Window and Worker have different performance.timeOrigin values. Use an
// absolute monotonic timestamp, and never extrapolate beyond queued PCM.
// Bound stale feedback so a suspended/disconnected device cannot run on.
const elapsed = clock.running ? Math.min(0.25, Math.max(0,
(performance.timeOrigin + performance.now() - clock.sampledAtMs) / 1000)) : 0;
const time = clock.outputTime + elapsed;
let presented = clock.completedFrames;
for (const segment of clock.segments) {
if (time < segment.start) break;
presented = segment.firstFrame + Math.min(segment.frames,
Math.max(0, (time - segment.start) * NATIVE_AUDIO_RATE));
}
// Device latency estimates may move slightly; never rewind the scene for
// that adjustment. Only a native seek/reset starts a new clock epoch.
this.presentedFrames = Math.min(this.mixedFrames, Math.max(this.presentedFrames, presented));
}
// Includes mixed PCM still in flight, not just nodes already scheduled by
// the Window. Before first output feedback the scene stays at its seek point.
return (this.mixedFrames - this.presentedFrames) / NATIVE_AUDIO_RATE;
}
private pull(request: NativeAudioPull): void {
if (request.type !== "pull") return;
if (request.generation !== this.generation) {
this.send({ type: "reset", generation: this.generation });
return;
}
const reply = { type: "pcm" as const, generation: this.generation, sequence: request.sequence };
if (!this.target || this.failed) { this.send(reply); return; }
try {
if (!Number.isInteger(request.blocks) || request.blocks < 1 || request.blocks > NATIVE_AUDIO_BUSY_BLOCKS) {
throw new Error("Invalid native audio buffer request");
}
const read = this.target._web_engine_audio_read;
if (!read) throw new Error("Native Audaspace playback ABI is missing from this WASM artifact");
const bytes = NATIVE_AUDIO_FRAMES * NATIVE_AUDIO_CHANNELS * 4;
this.pointer ||= this.target._malloc(bytes * NATIVE_AUDIO_BUSY_BLOCKS);
if (!this.pointer) throw new Error("Native audio buffer allocation failed");
let blocks = 0;
for (; blocks < request.blocks; blocks++) {
const result = read(this.pointer + blocks * bytes, NATIVE_AUDIO_FRAMES);
// Native callbacks may invalidate the device while it is reading.
if (reply.generation !== this.generation) return;
if (result < 0) throw new Error("Native Audaspace playback failed to decode or mix audio");
if (result === 0) break;
}
if (!blocks) { this.send(reply); return; }
this.mixedFrames += blocks * NATIVE_AUDIO_FRAMES;
const samples = this.target.HEAPU8.slice(this.pointer, this.pointer + blocks * bytes).buffer;
this.send({ ...reply, samples }, [samples]);
}
catch (error) {
// A native reset retires this pull, including errors raised while unwinding it.
if (reply.generation !== this.generation) return;
this.failed = true;
this.send({ type: "error", generation: this.generation, message: String(error) });
}
}
}