@call-me-sensei/angelo 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // Before a live frame is parsed: an image the page already holds (same `angelo_digest`) is pointed at a 1 x 1
5
+ // stub, exactly as Angelo marks one (`angelo_stub`), so the loader never decodes a picture the page has. A full
6
+ // frame carries every image; without this, each one would cost a decode on every frame even when nothing changed.
7
+
8
+ const STUB = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
9
+ 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 218, 99, 96, 0, 2, 0, 0, 5, 0, 1, 233, 250, 220, 216, 0, 0,
10
+ 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]); // A valid transparent 1 x 1 RGBA PNG.
11
+
12
+ const JSON_CHUNK = 0x4e4f534a;
13
+ const BIN_CHUNK = 0x004e4942;
14
+
15
+ /**
16
+ * `bytes` (an ArrayBuffer of a .glb) with every image whose digest `known(digest)` accepts turned into a stub.
17
+ * Returns the input unchanged when there is nothing to do or it isn't a .glb this can read.
18
+ */
19
+ export function stubKnownImages(bytes, known) {
20
+ const view = new DataView(bytes);
21
+ if (bytes.byteLength < 20 || view.getUint32(0, true) !== 0x46546c67 || view.getUint32(16, true) !== JSON_CHUNK) {
22
+ return bytes;
23
+ }
24
+ const jsonLength = view.getUint32(12, true);
25
+ let json;
26
+ try {
27
+ json = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 20, jsonLength)));
28
+ } catch {
29
+ return bytes;
30
+ }
31
+ const images = (json.images ?? []).filter((image) => image.bufferView !== undefined && image.extras?.angelo_stub !== true
32
+ && typeof image.extras?.angelo_digest === "string" && known(image.extras.angelo_digest));
33
+ const binAt = 20 + jsonLength;
34
+ if (!images.length || binAt + 8 > bytes.byteLength || view.getUint32(binAt + 4, true) !== BIN_CHUNK) return bytes;
35
+ const binLength = view.getUint32(binAt, true);
36
+ const stubAt = Math.ceil(binLength / 4) * 4;
37
+ for (const image of images) {
38
+ // One view per image, all over the same bytes: GLTFLoader caches decoded images by bufferView, so a shared view
39
+ // would make every stub one texture.
40
+ json.bufferViews.push({ buffer: 0, byteOffset: stubAt, byteLength: STUB.length });
41
+ image.bufferView = json.bufferViews.length - 1;
42
+ image.mimeType = "image/png";
43
+ image.extras.angelo_stub = true;
44
+ }
45
+ json.buffers[0].byteLength = stubAt + STUB.length;
46
+ let text = new TextEncoder().encode(JSON.stringify(json));
47
+ const padded = Math.ceil(text.length / 4) * 4;
48
+ const newBin = Math.ceil((stubAt + STUB.length) / 4) * 4;
49
+ const out = new Uint8Array(12 + 8 + padded + 8 + newBin);
50
+ const outView = new DataView(out.buffer);
51
+ outView.setUint32(0, 0x46546c67, true);
52
+ outView.setUint32(4, 2, true);
53
+ outView.setUint32(8, out.length, true);
54
+ outView.setUint32(12, padded, true);
55
+ outView.setUint32(16, JSON_CHUNK, true);
56
+ out.set(text, 20);
57
+ out.fill(0x20, 20 + text.length, 20 + padded);
58
+ text = null;
59
+ const at = 20 + padded;
60
+ outView.setUint32(at, newBin, true);
61
+ outView.setUint32(at + 4, BIN_CHUNK, true);
62
+ out.set(new Uint8Array(bytes, binAt + 8, binLength), at + 8);
63
+ out.set(STUB, at + 8 + stubAt);
64
+ return out.buffer;
65
+ }
@@ -0,0 +1,385 @@
1
+ // SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // The browser SDK (development builds only; production resolves to stub.js). It talks only to its own dev server:
5
+ // events over Vite's HMR socket (import.meta.hot), bytes over same-origin fetch under the bridge's token path.
6
+ //
7
+ // const live = createAngeloLive({ renderer, scene, loader });
8
+ // live.track(gltf, "/models/chair.glb");
9
+ //
10
+ // Each tracked asset keeps a queue of jobs (a live scene, a saved file, a restore) run one at a time; a newer live
11
+ // scene replaces one that hasn't started, so a slow page skips versions instead of falling behind.
12
+
13
+ import config from "virtual:angelo-live/config";
14
+ import { REVISION, Raycaster, Vector2 } from "three";
15
+ import { Capturer, hookRenderer } from "./capture.js";
16
+ import { attached, enqueue, resync } from "./jobs.js";
17
+ import { TextureCache } from "./textures.js";
18
+ import { adoptClone, adoptGltf } from "./matcher.js";
19
+ import { Overlay } from "./overlay.js";
20
+ import { Resources } from "./resources.js";
21
+ import { nameOf } from "./source.js";
22
+
23
+ const PREFIX = "Angelo:";
24
+ const RESTORE_GRACE_MS = 15000; // After losing Angelo, how long the page keeps showing its edits before restoring.
25
+
26
+ function randomId() {
27
+ const bytes = new Uint8Array(12);
28
+ crypto.getRandomValues(bytes);
29
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ function fileName(url) {
33
+ return decodeURIComponent(url.split("/").pop() || url);
34
+ }
35
+
36
+
37
+ class AngeloLive {
38
+ constructor(options) {
39
+ const { renderer, scene = null, loader, camera = null, look = "ignore", capture = true, overlay = true } = options;
40
+ if (!loader || typeof loader.loadAsync !== "function" || (typeof loader.parseAsync !== "function"
41
+ && typeof loader.parse !== "function")) {
42
+ throw new TypeError("createAngeloLive needs your GLTFLoader as `loader` (it decodes with your settings).");
43
+ }
44
+ if (look !== "ignore" && look !== "apply") throw new TypeError('`look` is "ignore" or "apply".');
45
+ this.renderer = renderer ?? null;
46
+ this.scene = scene;
47
+ this.loader = loader;
48
+ this.camera = camera;
49
+ this.look = look; // Reserved: Angelo 1.0.10 sends no look yet, so nothing is applied.
50
+ this.captureOn = capture !== false;
51
+ this.tab = randomId();
52
+ this.hot = import.meta.hot;
53
+ this.assets = new Map(); // page URL -> {url, copies, template, queue, busy, live, latest, version, opened}
54
+ this.listeners = new Map();
55
+ this.resources = new Resources();
56
+ this.cache = new TextureCache();
57
+ this.link = { link: "off", message: "", session: 0 };
58
+ this.overlay = new Overlay({ showDot: overlay !== false });
59
+ this.stopped = false;
60
+ this.handlers = [];
61
+ this.capturer = this.renderer ? new Capturer({
62
+ renderer: this.renderer,
63
+ send: (id, blob) => this.post(`capture/${encodeURIComponent(id)}`, blob),
64
+ fail: (id, message) => this.send("angelo:capture_failed", { id, message }),
65
+ }) : null;
66
+ this.unhook = this.renderer ? hookRenderer(this.renderer, (s, c) => this.capturer.rendered(s, c)) : () => {};
67
+ this.onPointer = (event) => this.pick(event);
68
+ this.renderer?.domElement?.addEventListener("pointerdown", this.onPointer);
69
+ if (!this.hot) {
70
+ console.info(`${PREFIX} the live link needs Vite's dev server with HMR on; it is off on this page.`);
71
+ this.setLink({ link: "off", message: "No dev-server connection (HMR is off)." });
72
+ return;
73
+ }
74
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(location.hostname)) {
75
+ console.info(`${PREFIX} the live link works only on this computer (http://localhost); it is off on this page.`);
76
+ this.setLink({ link: "off", message: "The live link works only on this computer." });
77
+ this.hot = null;
78
+ return;
79
+ }
80
+ this.listen("angelo:state", (data) => this.setLink(data));
81
+ this.listen("angelo:pending", ({ asset }) => this.notice(asset, "pending",
82
+ `Angelo is asking whether to open ${fileName(asset)}.`));
83
+ this.listen("angelo:opened", ({ asset }) => {
84
+ const found = this.assets.get(asset);
85
+ if (found) {
86
+ found.opened = true;
87
+ found.heardAt = performance.now();
88
+ }
89
+ this.notice(asset, "opened", `${fileName(asset)} is open in Angelo. Changes made there show here live.`);
90
+ this.updateDot();
91
+ });
92
+ this.listen("angelo:refused", ({ asset, code, message }) => {
93
+ console.warn(`${PREFIX} ${message || "Angelo didn't open the asset."}`);
94
+ this.emit("refused", { asset, code, message });
95
+ });
96
+ this.listen("angelo:scene", (data) => this.onScene(data));
97
+ this.listen("angelo:saved", ({ asset, save, url, scene_version: sceneVersion }) => {
98
+ this.enqueue(asset, { kind: "saved", save, url, sceneVersion });
99
+ });
100
+ this.listen("angelo:status", ({ asset, code, message }) => {
101
+ if (message) console[String(code).endsWith("failed") ? "warn" : "info"](`${PREFIX} ${message}`);
102
+ this.emit("status", { asset, code, message });
103
+ });
104
+ this.listen("angelo:ended", ({ asset, reason }) => this.onEnded(asset, reason));
105
+ this.listen("angelo:capture", (data) => this.onCapture(data));
106
+ this.send("angelo:hello", { three: REVISION, tracked: [], capture: this.captureOn, look: this.look });
107
+ }
108
+
109
+ // Public API ----------------------------------------------------------------------------------------------------
110
+
111
+ /** Follow Angelo's edits of the asset at `url` in `target`: a GLTFLoader result, or its scene or a clone of it. */
112
+ track(target, url) {
113
+ const key = this.key(url);
114
+ if (!key || !target) return { untrack() {} };
115
+ const asset = this.asset(key);
116
+ const isGltf = Boolean(target.scene && target.scene.isObject3D);
117
+ const root = isGltf ? target.scene : target;
118
+ if (!root.isObject3D) throw new TypeError("track() takes a GLTFLoader result or an Object3D.");
119
+ const existing = asset.copies.find((copy) => copy.root === root);
120
+ if (existing) return { untrack: () => this.untrack(root) };
121
+ if (isGltf && target.parser) {
122
+ const copy = adoptGltf(target, root);
123
+ // Its pictures (so an unchanged one is never decoded again) and its materials as the file has them.
124
+ this.enqueue(key, { kind: "digest", gltf: target, copy });
125
+ this.addCopy(asset, copy);
126
+ }
127
+ else if (asset.template) this.addCopy(asset, adoptClone(asset.template, root));
128
+ else {
129
+ // A bare Object3D with no template yet: learn the asset's structure from the file, then map the object to it.
130
+ this.enqueue(key, { kind: "adopt", root });
131
+ }
132
+ return { untrack: () => this.untrack(root) };
133
+ }
134
+
135
+ /** Stop following a copy (an Object3D) or every copy of a URL. Nothing is restored. */
136
+ untrack(target) {
137
+ for (const [key, asset] of this.assets) {
138
+ const before = asset.copies.length;
139
+ asset.copies = typeof target === "string" ? (this.key(target) === key ? [] : asset.copies)
140
+ : asset.copies.filter((copy) => copy.root !== target);
141
+ if (asset.copies.length !== before && !asset.copies.length) {
142
+ asset.template = null;
143
+ this.send("angelo:untrack", { url: key });
144
+ }
145
+ }
146
+ this.sweep();
147
+ }
148
+
149
+ /** Ask Angelo to open the asset (the person decides on a card in Angelo). */
150
+ edit(url, node = "") {
151
+ const key = this.key(url);
152
+ if (!key) return;
153
+ if (!this.hot) {
154
+ console.warn(`${PREFIX} the live link is off on this page.`);
155
+ return;
156
+ }
157
+ this.send("angelo:edit", { url: key, node: String(node).slice(0, 200) });
158
+ }
159
+
160
+ /** Listen to an event ("renamed", "removed", "added", "replaced", "updated", "saved", "ended", "state", ...). */
161
+ on(name, listener) {
162
+ const event = String(name).replace(/^angelo:/, "");
163
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
164
+ this.listeners.get(event).add(listener);
165
+ return () => this.listeners.get(event)?.delete(listener);
166
+ }
167
+
168
+ get state() {
169
+ return { ...this.link, assets: [...this.assets.values()].map((asset) => ({ url: asset.url,
170
+ copies: asset.copies.length, opened: asset.opened, live: asset.live, version: asset.version })) };
171
+ }
172
+
173
+ /** Put every tracked copy back as last saved, then stop. */
174
+ async stop() {
175
+ if (this.stopped || this.stopping) return;
176
+ this.stopping = true;
177
+ // The page's own objects stop hearing from this link at once (a component that remounts makes another).
178
+ this.capturer?.cancel();
179
+ this.unhook();
180
+ this.renderer?.domElement?.removeEventListener("pointerdown", this.onPointer);
181
+ this.overlay.remove();
182
+ const restores = [...this.assets.values()].filter((asset) => asset.live)
183
+ .map((asset) => this.enqueue(asset.url, { kind: "restore" }));
184
+ for (const asset of this.assets.values()) clearTimeout(asset.graceTimer);
185
+ await Promise.allSettled(restores);
186
+ for (const asset of this.assets.values()) if (asset.copies.length) this.send("angelo:untrack", { url: asset.url });
187
+ this.send("angelo:bye", {}); // The bridge forgets this tab (a page that unmounts its 3D view keeps its socket).
188
+ this.stopped = true;
189
+ for (const [event, handler] of this.handlers) this.hot?.off?.(event, handler);
190
+ }
191
+
192
+ // Plumbing ------------------------------------------------------------------------------------------------------
193
+
194
+ key(url) {
195
+ let parsed;
196
+ try {
197
+ parsed = new URL(String(url), location.href);
198
+ } catch {
199
+ return null;
200
+ }
201
+ if (parsed.origin !== location.origin) {
202
+ console.warn(`${PREFIX} only assets served by this dev server can be linked.`);
203
+ return null;
204
+ }
205
+ return parsed.pathname;
206
+ }
207
+
208
+ asset(key) {
209
+ let asset = this.assets.get(key);
210
+ if (!asset) {
211
+ asset = { url: key, copies: [], template: null, queue: [], busy: false, live: false, version: -1, session: -1,
212
+ opened: false, heardAt: 0, resyncFor: null, resyncSession: null, graceTimer: null };
213
+ this.assets.set(key, asset);
214
+ }
215
+ return asset;
216
+ }
217
+
218
+ addCopy(asset, copy) {
219
+ const first = asset.copies.length === 0;
220
+ asset.copies.push(copy);
221
+ if (!asset.template) asset.template = copy;
222
+ if (first) this.send("angelo:track", { url: asset.url });
223
+ if (asset.live) resync(this, asset, "joined"); // A whole frame brings it to the others' state, by diff.
224
+ }
225
+
226
+ listen(event, handler) {
227
+ const guarded = (data) => {
228
+ if (this.stopped || this.stopping || data === null || typeof data !== "object") return;
229
+ try {
230
+ handler(data);
231
+ } catch (error) {
232
+ console.error(`${PREFIX} ${error?.message ?? error}`);
233
+ }
234
+ };
235
+ this.hot.on(event, guarded);
236
+ this.handlers.push([event, guarded]);
237
+ }
238
+
239
+ send(event, data) {
240
+ if (this.hot && !this.stopped) this.hot.send(event, { tab: this.tab, ...data });
241
+ }
242
+
243
+ emit(name, detail) {
244
+ for (const listener of this.listeners.get(name) ?? []) {
245
+ try {
246
+ listener(detail);
247
+ } catch (error) {
248
+ console.error(error);
249
+ }
250
+ }
251
+ }
252
+
253
+ notice(asset, name, message) {
254
+ console.info(`${PREFIX} ${message}`);
255
+ this.emit(name, { asset });
256
+ }
257
+
258
+ async post(route, body) {
259
+ await fetch(`${config.base}${route}`, { method: "POST", body, credentials: "same-origin", cache: "no-store",
260
+ headers: { "Content-Type": "image/png" } });
261
+ }
262
+
263
+ setLink({ link, message = "", session }) {
264
+ const previous = this.link;
265
+ this.link = { link, message, session: Number.isInteger(session) ? session : previous.session };
266
+ if (link !== "live" && previous.link === "live") {
267
+ // Angelo may be back in a moment (a reconnect): wait before putting anything back, and then restore only if
268
+ // nothing was heard of the asset since. The page never reloads either way.
269
+ const lostAt = performance.now();
270
+ for (const asset of this.assets.values()) {
271
+ if (!asset.live) continue;
272
+ clearTimeout(asset.graceTimer);
273
+ asset.graceTimer = setTimeout(() => {
274
+ if (asset.heardAt < lostAt && asset.live) {
275
+ asset.opened = false;
276
+ this.enqueue(asset.url, { kind: "restore" });
277
+ }
278
+ }, RESTORE_GRACE_MS);
279
+ }
280
+ }
281
+ if (previous.link !== link && link === "live") console.info(`${PREFIX} linked to Angelo.`);
282
+ if (previous.link === "live" && link !== "live") console.warn(`${PREFIX} ${message || "lost Angelo."}`);
283
+ this.updateDot();
284
+ this.emit("state", { ...this.link });
285
+ }
286
+
287
+ updateDot() {
288
+ const busy = [...this.assets.values()].some((asset) => asset.busy);
289
+ const linked = [...this.assets.values()].filter((asset) => asset.opened);
290
+ const { link, message } = this.link;
291
+ if (link === "live" && busy) return this.overlay.setState("updating", "Angelo: updating");
292
+ if (link === "live" && linked.length) {
293
+ return this.overlay.setState("linked", `Live in Angelo: ${linked.map((a) => fileName(a.url)).join(", ")}`);
294
+ }
295
+ this.overlay.setState(link, `Angelo: ${message || link}`);
296
+ }
297
+
298
+ // Jobs (jobs.js) ------------------------------------------------------------------------------------------------
299
+
300
+ onScene({ asset: key, version, url, session, kind, base, removed }) {
301
+ const asset = this.assets.get(key);
302
+ if (!asset || !asset.copies.length || !Number.isInteger(version)) return;
303
+ if (session === asset.session && version <= asset.version) return;
304
+ asset.opened = true;
305
+ asset.heardAt = performance.now();
306
+ const frame = kind === "delta" ? "delta" : "full";
307
+ this.enqueue(key, { kind: "scene", frame, version, url, session, base,
308
+ removed: Array.isArray(removed) ? removed.filter((uid) => typeof uid === "string") : [] });
309
+ }
310
+
311
+ onEnded(key, reason) {
312
+ const asset = this.assets.get(key);
313
+ if (!asset) return;
314
+ asset.opened = false;
315
+ if (reason !== "engine") {
316
+ console.info(`${PREFIX} Angelo stopped editing ${fileName(key)}. It shows here as last saved.`);
317
+ }
318
+ if (asset.live) this.enqueue(key, { kind: "restore" });
319
+ this.emit("ended", { asset: key, reason });
320
+ this.updateDot();
321
+ }
322
+
323
+ enqueue(key, job) {
324
+ const asset = this.assets.get(key);
325
+ return asset ? enqueue(this, asset, job) : Promise.resolve();
326
+ }
327
+
328
+ sweep() {
329
+ const roots = [];
330
+ const keep = [];
331
+ for (const asset of this.assets.values()) {
332
+ for (const copy of asset.copies) {
333
+ roots.push(copy.root);
334
+ keep.push(...copy.journal.resources()); // What a restore may put back.
335
+ }
336
+ }
337
+ this.resources.sweep(roots, keep, (item) => item.isTexture && this.cache.retains(item));
338
+ }
339
+
340
+ // Alt-click and captures ----------------------------------------------------------------------------------------
341
+
342
+ pick(event) {
343
+ if (!event.altKey || event.button !== 0 || !this.hot) return;
344
+ const camera = this.camera ?? this.capturer?.lastCamera;
345
+ const canvas = this.renderer.domElement;
346
+ if (!camera) return;
347
+ const rect = canvas.getBoundingClientRect();
348
+ const pointer = new Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1,
349
+ -((event.clientY - rect.top) / rect.height) * 2 + 1);
350
+ const raycaster = new Raycaster();
351
+ raycaster.setFromCamera(pointer, camera);
352
+ let best = null;
353
+ for (const asset of this.assets.values()) {
354
+ for (const copy of asset.copies) {
355
+ const hit = raycaster.intersectObject(copy.root, true)[0];
356
+ if (hit && (!best || hit.distance < best.hit.distance)) best = { hit, asset, copy };
357
+ }
358
+ }
359
+ if (!best) return;
360
+ let node = best.hit.object;
361
+ while (node && !best.copy.nodes.has(node) && node !== best.copy.root) node = node.parent;
362
+ const nodeName = node && node !== best.copy.root ? nameOf(node) : "";
363
+ const { url } = best.asset;
364
+ this.overlay.showChip(event.clientX, event.clientY, `Edit ${fileName(url)} in Angelo`, () => this.edit(url, nodeName));
365
+ }
366
+
367
+ onCapture({ id, asset: key, max }) {
368
+ if (typeof id !== "string") return;
369
+ const fail = (message) => this.send("angelo:capture_failed", { id, message });
370
+ if (!this.captureOn) return fail("capture is off on this page");
371
+ if (!this.capturer) return fail("No renderer was given to the live link");
372
+ if (document.visibilityState === "hidden") return fail("The tab is hidden, so it draws nothing");
373
+ const asset = this.assets.get(key);
374
+ if (!asset || !asset.copies.some((copy) => attached(copy.root))) {
375
+ return fail("No tracked copy of the asset is on this page");
376
+ }
377
+ this.capturer.request(id, Number.isInteger(max) && max > 0 ? max : 1280);
378
+ }
379
+ }
380
+
381
+ /** Start the live link on this page. See the README for the options. */
382
+ export function createAngeloLive(options = {}) {
383
+ return new AngeloLive(options);
384
+ }
385
+
@@ -0,0 +1,229 @@
1
+ // SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // Each tracked asset's jobs, run one at a time: a live scene (full or delta), a saved file, a restore. A newer full
5
+ // scene replaces queued scenes; deltas are kept in order, since each applies on the one before. Nothing here ever
6
+ // reloads the page or rebuilds the developer's objects: every job is a diff against what the page shows.
7
+
8
+ import { applyDelta, describeDelta } from "./delta.js";
9
+ import { adoptClone, adoptGltf, applySource, describe, forgetUids, rememberFileMaterials } from "./matcher.js";
10
+ import { stubKnownImages } from "./glb.js";
11
+ import { digestTextures } from "./textures.js";
12
+
13
+ const PREFIX = "Angelo:";
14
+
15
+
16
+ function fileName(url) {
17
+ return decodeURIComponent(url.split("/").pop() || url);
18
+ }
19
+
20
+ export function attached(root) {
21
+ for (let object = root; object; object = object.parent) if (object.isScene) return true;
22
+ return false;
23
+ }
24
+
25
+ /** The developer's loader's parse: `parseAsync` where it has one (three r146+), else its callback `parse`. */
26
+ function parseWith(loader, bytes) {
27
+ if (typeof loader.parseAsync === "function") return loader.parseAsync(bytes, "");
28
+ return new Promise((resolve, reject) => loader.parse(bytes, "", resolve, reject));
29
+ }
30
+
31
+ export function enqueue(live, asset, job) {
32
+ const replaced = (queued) => (queued.kind === "scene" && (job.kind === "restore"
33
+ || (job.kind === "scene" && job.frame !== "delta"))) || (queued.kind === "restore" && job.kind === "restore");
34
+ for (const queued of asset.queue.filter(replaced)) queued.resolve();
35
+ asset.queue = asset.queue.filter((queued) => !replaced(queued));
36
+ return new Promise((resolve) => {
37
+ asset.queue.push({ ...job, resolve });
38
+ if (!asset.busy) {
39
+ asset.busy = true;
40
+ pump(live, asset);
41
+ }
42
+ });
43
+ }
44
+
45
+ async function pump(live, asset) {
46
+ live.updateDot();
47
+ while (asset.queue.length) {
48
+ const job = asset.queue.shift();
49
+ try {
50
+ await run(live, asset, job);
51
+ } catch (error) {
52
+ console.error(`${PREFIX} ${error?.message ?? error}`);
53
+ }
54
+ job.resolve();
55
+ }
56
+ asset.busy = false;
57
+ live.updateDot();
58
+ }
59
+
60
+ /** Ask Angelo for a whole frame (a delta the page can't apply, or a copy that joined mid-session). */
61
+ function resync(live, asset, reason) {
62
+ // Once per version the page shows: Angelo's full frame answers it, and a delta after that is a new question.
63
+ if (asset.resyncFor === asset.version && asset.resyncSession === asset.session) return;
64
+ asset.resyncFor = asset.version;
65
+ asset.resyncSession = asset.session;
66
+ live.send("angelo:resync", { url: asset.url, version: asset.version, reason });
67
+ live.emit("resync", { asset: asset.url, reason });
68
+ }
69
+
70
+ async function remember(live, gltf) {
71
+ const textures = await digestTextures(gltf);
72
+ for (const { texture, stub } of textures) if (texture && !stub) live.cache.remember(texture);
73
+ return textures;
74
+ }
75
+
76
+ function report(live, asset, reports) {
77
+ let moved = 0;
78
+ let changed = 0;
79
+ for (const result of reports) {
80
+ for (const skeleton of result.skeletons) live.resources.add(skeleton);
81
+ changed += result.changed.size;
82
+ for (const { object, from, to } of result.renamed) {
83
+ console.info(`${PREFIX} "${from}" is now "${to}" in ${fileName(asset.url)}.`);
84
+ live.emit("renamed", { asset: asset.url, object, from, to });
85
+ }
86
+ for (const { name, moved: children, to } of result.removed) {
87
+ moved += children.length;
88
+ live.emit("removed", { asset: asset.url, name, moved: children, to });
89
+ }
90
+ if (result.added.length) live.emit("added", { asset: asset.url, objects: result.added });
91
+ for (const { from, to } of result.replaced) live.emit("replaced", { asset: asset.url, from, to });
92
+ }
93
+ if (moved) {
94
+ console.warn(`${PREFIX} objects were deleted in Angelo; ${moved} of your own object(s) under them moved to the `
95
+ + "nearest remaining part of the asset.");
96
+ }
97
+ return changed;
98
+ }
99
+
100
+ function onScreen(asset) {
101
+ return asset.copies.filter((copy) => attached(copy.root)).length;
102
+ }
103
+
104
+ async function run(live, asset, job) {
105
+ if (job.kind === "digest") {
106
+ await remember(live, job.gltf);
107
+ if (job.copy) await rememberFileMaterials(job.gltf, job.copy);
108
+ return;
109
+ }
110
+ if (job.kind === "adopt") {
111
+ // A bare Object3D with no template: learn the asset's structure from its file, then map the object to it.
112
+ const base = await live.loader.loadAsync(asset.url);
113
+ live.resources.adopt(base.scene);
114
+ await remember(live, base);
115
+ live.addCopy(asset, adoptClone(asset.template ?? adoptGltf(base), job.root));
116
+ live.sweep();
117
+ return;
118
+ }
119
+ if (!asset.copies.length) return;
120
+ if (job.kind === "scene") return runScene(live, asset, job);
121
+ if (job.kind === "saved") return runSaved(live, asset, job);
122
+ if (job.kind === "restore") {
123
+ for (const copy of asset.copies) copy.journal.restore(copy);
124
+ if (![...live.assets.values()].some((other) => other.live && other !== asset)) live.cache.clear();
125
+ asset.live = false;
126
+ asset.version = -1; // The next frame must be whole: the page no longer shows any of Angelo's versions.
127
+ live.sweep();
128
+ live.emit("restored", { asset: asset.url, instances: onScreen(asset) });
129
+ }
130
+ }
131
+
132
+ async function runScene(live, asset, job) {
133
+ if (job.session !== asset.session) {
134
+ for (const copy of asset.copies) forgetUids(copy); // Another Angelo process: its uids mean other nodes.
135
+ asset.session = job.session;
136
+ asset.version = -1;
137
+ }
138
+ const delta = job.frame === "delta";
139
+ if (delta && job.base !== asset.version) return resync(live, asset, "base");
140
+ const ack = (fields) => live.send("angelo:ack", { asset: asset.url, version: job.version, ...fields });
141
+ const started = performance.now();
142
+ let gltf;
143
+ let textures;
144
+ let fetched;
145
+ let parsed;
146
+ let reparsed = false;
147
+ let stubbed = 0;
148
+ try {
149
+ const response = await fetch(job.url, { credentials: "same-origin", cache: "no-store" });
150
+ if (response.status === 404) return delta ? resync(live, asset, "gone") : undefined; // A newer one replaced it.
151
+ if (!response.ok) throw new Error(`the dev server answered ${response.status}`);
152
+ const bytes = await response.arrayBuffer();
153
+ fetched = performance.now();
154
+ // Pictures the page holds aren't decoded again: they become stubs, resolved from the cache below.
155
+ const light = stubKnownImages(bytes, (digest) => live.cache.hasDigest(digest));
156
+ gltf = await parseWith(live.loader, light);
157
+ textures = await remember(live, gltf);
158
+ reparsed = light !== bytes && textures.some(({ texture, stub }) => stub && !live.cache.has(texture));
159
+ if (reparsed) {
160
+ live.resources.adopt(gltf.scene); // The page's copy has another sampler: decode this frame's own pictures.
161
+ gltf = await parseWith(live.loader, bytes);
162
+ textures = await remember(live, gltf);
163
+ }
164
+ stubbed = textures.filter(({ stub }) => stub).length;
165
+ parsed = performance.now();
166
+ } catch (error) {
167
+ return ack({ code: "preview_failed", message: `Couldn't load the live scene: ${error?.message ?? error}` });
168
+ }
169
+ if (textures.some(({ texture, stub }) => stub && !live.cache.has(texture))) {
170
+ live.resources.adopt(gltf.scene);
171
+ live.sweep();
172
+ return resync(live, asset, "texture");
173
+ }
174
+ live.resources.adopt(gltf.scene);
175
+ const reports = [];
176
+ if (delta) {
177
+ const description = describeDelta(gltf);
178
+ for (const copy of asset.copies) {
179
+ const result = applyDelta(copy, description, job.removed ?? [], live.cache);
180
+ if (result.problem) {
181
+ live.sweep();
182
+ console.info(`${PREFIX} asking Angelo for the whole scene (${result.problem}).`);
183
+ return resync(live, asset, "delta");
184
+ }
185
+ reports.push(result.report);
186
+ }
187
+ } else {
188
+ const source = describe(gltf);
189
+ for (const copy of asset.copies) reports.push(applySource(copy, source, live.cache));
190
+ }
191
+ const applied = performance.now();
192
+ const changed = report(live, asset, reports);
193
+ asset.live = true;
194
+ asset.version = job.version;
195
+ live.sweep();
196
+ const instances = onScreen(asset);
197
+ ack({ code: "previewed", instances });
198
+ live.emit("updated", { asset: asset.url, version: job.version, frame: delta ? "delta" : "full", instances, changed,
199
+ timing: { fetch: fetched - started, parse: parsed - fetched, apply: applied - parsed }, stubbed, reparsed });
200
+ }
201
+
202
+ async function runSaved(live, asset, job) {
203
+ const ack = (fields) => live.send("angelo:ack", { asset: asset.url, save: job.save, ...fields });
204
+ const load = () => live.loader.loadAsync(job.url);
205
+ // When the page already shows the version that was saved, there is nothing to change: no fetch, no upload.
206
+ const shown = Number.isInteger(job.sceneVersion) && job.sceneVersion === asset.version && asset.live;
207
+ if (!shown) {
208
+ let gltf;
209
+ try {
210
+ gltf = await load();
211
+ await remember(live, gltf);
212
+ } catch (error) {
213
+ return ack({ code: "reimport_failed", message: `Couldn't load the saved file: ${error?.message ?? error}` });
214
+ }
215
+ live.resources.adopt(gltf.scene);
216
+ const source = describe(gltf);
217
+ report(live, asset, asset.copies.map((copy) => applySource(copy, source, live.cache)));
218
+ if (Number.isInteger(job.sceneVersion)) asset.version = job.sceneVersion;
219
+ }
220
+ for (const copy of asset.copies) copy.journal.reset(); // What shows is now the asset as saved.
221
+ asset.live = false;
222
+ live.sweep();
223
+ const instances = onScreen(asset);
224
+ ack({ code: "reimported", instances });
225
+ live.emit("saved", { asset: asset.url, save: job.save, instances, load });
226
+ }
227
+
228
+ export { resync };
229
+