Files
yawn/examples/render-graph-studio/render-graph/authoring-controller.js
T

121 lines
5.7 KiB
JavaScript

import { mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
import { loadGraph } from "@yawn/render-graph-js";
// Example lifecycle glue; package frontends remain independent of this controller.
export class AuthoringController {
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
#current; #lastGood; #applyPromise; #listeners = new Set();
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
this.#renderer = renderer; this.#adapt = adapt;
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
}
get revision() { return this.#revision; }
get dirty() { return !!this.#current; }
get applying() { return !!this.#applyPromise; }
get staged() { return this.#current?.candidate ?? null; }
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
subscribe(fn) {
if (this.#disposed) return () => {};
this.#listeners.add(fn); fn(this.#state());
return () => this.#listeners.delete(fn);
}
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
#key(id) { return JSON.stringify(id); }
#drop(candidate) {
if (!candidate) return Promise.resolve();
const key = this.#key(candidate.compiledId);
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
if (this.#drops.has(key)) return this.#drops.get(key);
let result;
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
catch (error) { result = Promise.reject(error); }
const dropping = Promise.resolve(result)
.then(() => { this.#owned.delete(key); })
.finally(() => { this.#drops.delete(key); });
this.#drops.set(key, dropping);
return dropping;
}
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
#start(record) {
if (!record.compile) {
record.compile = this.#compile(record);
this.#activeCompiles.add(record.compile);
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
}
return record.compile;
}
#flush(record = this.#current) {
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
return record ? this.#start(record) : null;
}
markDirty(snapshot) {
if (this.#disposed) return;
const previous = this.#current;
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
this.#current = record;
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
this.#emit();
}
async #compile(record) {
let candidate, ir;
try {
ir = this.#adapt(record.snapshot, this.#nextRevision++);
candidate = await loadGraph(this.#renderer, ir);
this.#owned.set(this.#key(candidate.compiledId), candidate);
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
} catch (error) {
if (candidate) this.#retire(candidate);
record.error = error;
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
if (this.#current === record) this.#emit();
return null;
}
}
apply() {
if (this.#disposed) return Promise.resolve(null);
if (this.#applyPromise) return this.#applyPromise;
const record = this.#current;
if (!record) return Promise.resolve(this.#lastGood);
this.#applyingRecord = record; this.#flush(record);
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
}
async #applyRecord(record) {
try {
const candidate = record.candidate ?? (await record.compile);
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
await this.#renderer.switchCompiledGraph(candidate.compiledId);
const old = this.#lastGood; this.#lastGood = candidate;
this.#revision = candidate.revision ?? this.#revision + 1;
if (this.#current === record) this.#current = undefined;
if (old && old !== candidate) this.#retire(old);
return candidate;
} finally {
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
}
}
destroy() {
if (this.#destroyPromise) return this.#destroyPromise;
this.#disposed = true;
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
this.#listeners.clear();
this.#destroyPromise = this.#finish();
return this.#destroyPromise;
}
async #finish() {
const applying = this.#applyPromise;
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
this.#current = this.#lastGood = undefined;
}
}