chore: checkpoint web Blender migration source, tools and docs
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:
212
web/app/src/engine-client/NativeAudioOutput.ts
Normal file
212
web/app/src/engine-client/NativeAudioOutput.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
142
web/app/src/engine-client/NativeResourceClient.ts
Normal file
142
web/app/src/engine-client/NativeResourceClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
web/app/src/engine-client/NativeSessionClient.ts
Normal file
136
web/app/src/engine-client/NativeSessionClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
182
web/app/src/engine-client/PythonSessionClient.ts
Normal file
182
web/app/src/engine-client/PythonSessionClient.ts
Normal 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
87
web/app/src/engine-client/embedded-render.ts
Normal file
87
web/app/src/engine-client/embedded-render.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
web/app/src/engine-client/gpu-compute-host.ts
Normal file
131
web/app/src/engine-client/gpu-compute-host.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
45
web/app/src/engine-client/gpu-compute-relay.ts
Normal file
45
web/app/src/engine-client/gpu-compute-relay.ts
Normal 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;}
|
||||
}
|
||||
161
web/app/src/engine-client/native-audio-transport.ts
Normal file
161
web/app/src/engine-client/native-audio-transport.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user