cozyclay 1.2.0 → 1.3.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/project.js ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * CozyClay project files — game-engine style.
3
+ *
4
+ * A project is ONE file (`.cclayproject`, JSON) holding the full authoring
5
+ * state: the scene document (scenes + cast + animation layers), the
6
+ * workspace layout, and the custom pose library. Editor preferences
7
+ * (theme, locale) deliberately stay OUT — they belong to the operator, not
8
+ * the project.
9
+ *
10
+ * Files are read/written with the File System Access API when available,
11
+ * with a plain download/upload fallback. The last-used handle is kept in
12
+ * IndexedDB so a refresh can re-open the same project when the browser
13
+ * still grants access.
14
+ */
15
+
16
+ import { SCENES_VERSION } from "./scenes.js";
17
+
18
+ export const PROJECT_VERSION = 1;
19
+ export const PROJECT_EXTENSION = ".cclayproject";
20
+ const IDB_NAME = "cozyclay.project-handle.v1";
21
+ const IDB_STORE = "kv";
22
+ const IDB_KEY = "lastProjectHandle";
23
+
24
+ /* ------------------------------- envelope ------------------------------- */
25
+
26
+ export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name }) {
27
+ return {
28
+ app: "cozyclay",
29
+ kind: "project",
30
+ version: PROJECT_VERSION,
31
+ name: typeof name === "string" && name.trim() ? name.trim() : "Untitled",
32
+ scenes: scenesDocument,
33
+ workspace: workspaceLayout ?? null,
34
+ poseLibrary: Array.isArray(customPoses) ? customPoses : [],
35
+ };
36
+ }
37
+
38
+ /** Parse + validate a project file. Returns { ok, reason?, project? }. */
39
+ export function readProjectDocument(raw) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ return { ok: false, reason: "corrupt" };
45
+ }
46
+ if (!parsed || typeof parsed !== "object") return { ok: false, reason: "corrupt" };
47
+ if (parsed.app !== "cozyclay" || parsed.kind !== "project") return { ok: false, reason: "not-a-project" };
48
+ if (typeof parsed.version !== "number") return { ok: false, reason: "corrupt" };
49
+ if (parsed.version > PROJECT_VERSION) return { ok: false, reason: "future" };
50
+ const scenes = parsed.scenes;
51
+ if (!scenes || typeof scenes !== "object" || scenes.version > SCENES_VERSION || !Array.isArray(scenes.scenes)) {
52
+ return { ok: false, reason: "scenes-invalid" };
53
+ }
54
+ return {
55
+ ok: true,
56
+ project: {
57
+ name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : "Untitled",
58
+ scenesDocument: { version: scenes.version, activeSceneId: scenes.activeSceneId ?? null, scenes: scenes.scenes },
59
+ workspaceLayout: parsed.workspace && typeof parsed.workspace === "object" ? parsed.workspace : null,
60
+ customPoses: Array.isArray(parsed.poseLibrary)
61
+ ? parsed.poseLibrary.filter((p) => p && typeof p === "object" && typeof p.id === "string" && p.bones && typeof p.bones === "object")
62
+ : [],
63
+ },
64
+ };
65
+ }
66
+
67
+ /* ------------------------- file system access --------------------------- */
68
+
69
+ export function hasFileSystemAccess() {
70
+ return typeof window !== "undefined" && typeof window.showSaveFilePicker === "function" && typeof window.showOpenFilePicker === "function";
71
+ }
72
+
73
+ const PICKER_TYPES = [
74
+ {
75
+ description: "CozyClay Project",
76
+ accept: { "application/json": [PROJECT_EXTENSION, ".json"] },
77
+ },
78
+ ];
79
+
80
+ export async function pickProjectFileForSave(suggestedName) {
81
+ const handle = await window.showSaveFilePicker({
82
+ suggestedName: `${suggestedName || "Untitled"}${PROJECT_EXTENSION}`,
83
+ types: PICKER_TYPES,
84
+ });
85
+ return handle;
86
+ }
87
+
88
+ export async function pickProjectFileForOpen() {
89
+ const [handle] = await window.showOpenFilePicker({ types: PICKER_TYPES, multiple: false });
90
+ return handle;
91
+ }
92
+
93
+ export async function writeProjectFile(handle, serialized) {
94
+ const writable = await handle.createWritable();
95
+ await writable.write(serialized);
96
+ await writable.close();
97
+ }
98
+
99
+ export async function readProjectFile(handle) {
100
+ const file = await handle.getFile();
101
+ return { name: file.name.replace(/\.cclayproject$|\.json$/i, ""), text: await file.text() };
102
+ }
103
+
104
+ /* --------------------------- fallback (no FS API) ------------------------ */
105
+
106
+ export function downloadProjectFallback(serialized, name) {
107
+ const blob = new Blob([serialized], { type: "application/json" });
108
+ const url = URL.createObjectURL(blob);
109
+ const anchor = document.createElement("a");
110
+ anchor.href = url;
111
+ anchor.download = `${name || "Untitled"}${PROJECT_EXTENSION}`;
112
+ anchor.click();
113
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
114
+ }
115
+
116
+ export function openProjectFallback() {
117
+ return new Promise((resolve) => {
118
+ const input = document.createElement("input");
119
+ input.type = "file";
120
+ input.accept = `${PROJECT_EXTENSION},.json,application/json`;
121
+ input.onchange = async () => {
122
+ const file = input.files?.[0];
123
+ if (!file) return resolve(null);
124
+ resolve({ name: file.name.replace(/\.cclayproject$|\.json$/i, ""), text: await file.text() });
125
+ };
126
+ input.oncancel = () => resolve(null);
127
+ input.click();
128
+ });
129
+ }
130
+
131
+ /* ------------------------------- IDB handle ------------------------------ */
132
+
133
+ function openIdb() {
134
+ return new Promise((resolve, reject) => {
135
+ const request = indexedDB.open(IDB_NAME, 1);
136
+ request.onupgradeneeded = () => request.result.createObjectStore(IDB_STORE);
137
+ request.onsuccess = () => resolve(request.result);
138
+ request.onerror = () => reject(request.error);
139
+ });
140
+ }
141
+
142
+ export async function storeProjectHandle(handle, name) {
143
+ try {
144
+ const db = await openIdb();
145
+ await new Promise((resolve, reject) => {
146
+ const tx = db.transaction(IDB_STORE, "readwrite");
147
+ tx.objectStore(IDB_STORE).put({ handle, name }, IDB_KEY);
148
+ tx.oncomplete = resolve;
149
+ tx.onerror = () => reject(tx.error);
150
+ });
151
+ db.close();
152
+ } catch {
153
+ /* private mode etc.: the session just won't remember the handle */
154
+ }
155
+ }
156
+
157
+ export async function loadStoredProjectHandle() {
158
+ try {
159
+ const db = await openIdb();
160
+ const record = await new Promise((resolve, reject) => {
161
+ const tx = db.transaction(IDB_STORE, "readonly");
162
+ const request = tx.objectStore(IDB_STORE).get(IDB_KEY);
163
+ request.onsuccess = () => resolve(request.result ?? null);
164
+ request.onerror = () => reject(request.error);
165
+ });
166
+ db.close();
167
+ return record;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ export async function clearStoredProjectHandle() {
174
+ try {
175
+ const db = await openIdb();
176
+ await new Promise((resolve, reject) => {
177
+ const tx = db.transaction(IDB_STORE, "readwrite");
178
+ tx.objectStore(IDB_STORE).delete(IDB_KEY);
179
+ tx.oncomplete = resolve;
180
+ tx.onerror = () => reject(tx.error);
181
+ });
182
+ db.close();
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ }
187
+
188
+ /* --------------------- recent projects + projects folder ---------------------
189
+ * The browser (game-engine style project picker) needs two things the File
190
+ * System Access API cannot give for free: a HISTORY of what was opened
191
+ * (stored here), and an optional directory handle it can enumerate. */
192
+
193
+ const RECENTS_KEY = "recentProjects";
194
+ const FOLDER_KEY = "projectsDirectory";
195
+ const RECENTS_MAX = 8;
196
+
197
+ async function idbSet(key, value) {
198
+ try {
199
+ const db = await openIdb();
200
+ await new Promise((resolve, reject) => {
201
+ const tx = db.transaction(IDB_STORE, "readwrite");
202
+ tx.objectStore(IDB_STORE).put(value, key);
203
+ tx.oncomplete = resolve;
204
+ tx.onerror = () => reject(tx.error);
205
+ });
206
+ db.close();
207
+ } catch {
208
+ /* private mode etc. */
209
+ }
210
+ }
211
+
212
+ async function idbGet(key) {
213
+ try {
214
+ const db = await openIdb();
215
+ const record = await new Promise((resolve, reject) => {
216
+ const tx = db.transaction(IDB_STORE, "readonly");
217
+ const request = tx.objectStore(IDB_STORE).get(key);
218
+ request.onsuccess = () => resolve(request.result ?? null);
219
+ request.onerror = () => reject(request.error);
220
+ });
221
+ db.close();
222
+ return record;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /** Record a project open/save in the recents list (most recent first). */
229
+ export async function rememberRecentProject(handle, name) {
230
+ const list = (await idbGet(RECENTS_KEY)) ?? [];
231
+ const next = [
232
+ { handle, name, openedAt: Date.now() },
233
+ ...list.filter((entry) => entry.name !== name),
234
+ ].slice(0, RECENTS_MAX);
235
+ await idbSet(RECENTS_KEY, next);
236
+ await storeProjectHandle(handle, name);
237
+ }
238
+
239
+ export async function loadRecentProjects() {
240
+ return (await idbGet(RECENTS_KEY)) ?? [];
241
+ }
242
+
243
+ export async function removeRecentProject(name) {
244
+ const list = (await idbGet(RECENTS_KEY)) ?? [];
245
+ await idbSet(RECENTS_KEY, list.filter((entry) => entry.name !== name));
246
+ }
247
+
248
+ /* Projects directory: optional; when set, the browser can LIST the folder. */
249
+
250
+ export function hasDirectoryPicker() {
251
+ return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function";
252
+ }
253
+
254
+ export async function pickProjectsDirectory() {
255
+ return window.showDirectoryPicker({ mode: "readwrite" });
256
+ }
257
+
258
+ export async function storeProjectsDirectory(handle) {
259
+ await idbSet(FOLDER_KEY, { handle });
260
+ }
261
+
262
+ export async function loadProjectsDirectory() {
263
+ const record = await idbGet(FOLDER_KEY);
264
+ return record?.handle ?? null;
265
+ }
266
+
267
+ /** Enumerate `.cclayproject` files in a directory handle, newest first. */
268
+ export async function listProjectsInDirectory(dirHandle) {
269
+ const out = [];
270
+ try {
271
+ for await (const [name, handle] of dirHandle.entries()) {
272
+ if (handle.kind !== "file" || !name.endsWith(PROJECT_EXTENSION)) continue;
273
+ const file = await handle.getFile();
274
+ out.push({ name: name.slice(0, -PROJECT_EXTENSION.length), handle, lastModified: file.lastModified });
275
+ }
276
+ } catch {
277
+ return [];
278
+ }
279
+ return out.sort((a, b) => b.lastModified - a.lastModified);
280
+ }
281
+
282
+ /** "granted" | "prompt" | "denied" — never throws. */
283
+ export async function queryHandlePermission(handle) {
284
+ try {
285
+ return await handle.queryPermission({ mode: "readwrite" });
286
+ } catch {
287
+ return "denied";
288
+ }
289
+ }
package/src/props.jsx CHANGED
@@ -9,9 +9,11 @@
9
9
  * the 1.8 m figure keeps honest scale.
10
10
  */
11
11
 
12
- import { useEffect, useMemo } from "react";
12
+ import { useEffect, useMemo, useState } from "react";
13
13
  import * as THREE from "three";
14
14
  import { GIZMO_LAYER } from "./dualview.jsx";
15
+ import { CUTOUT_KIND } from "./scene-objects.js";
16
+ import { subscribeToAssetTexture } from "./scene-asset-cache.js";
15
17
 
16
18
  const CLAY_CAR = "#d98770";
17
19
  const CLAY_CAR_TOP = "#e49a84";
@@ -230,9 +232,73 @@ function Primitive({ kind, color }) {
230
232
  );
231
233
  }
232
234
 
235
+ /** The texture behind an `assetId`, or null while it loads (or forever, if the
236
+ * picture is gone). Subscribing rather than loading here means the same
237
+ * picture on two cards is one decode. */
238
+ function useAssetTexture(assetId) {
239
+ const [texture, setTexture] = useState(null);
240
+ useEffect(() => {
241
+ setTexture(null);
242
+ if (!assetId) return undefined;
243
+ return subscribeToAssetTexture(assetId, setTexture);
244
+ }, [assetId]);
245
+ return texture;
246
+ }
247
+
248
+ /** The placeholder tint for a card whose picture has not arrived (or has gone
249
+ * missing): blockout grey, because that is exactly what it is again. */
250
+ const MISSING_CUTOUT = "#c2c6c8";
251
+
252
+ /**
253
+ * A cutout: an imported picture standing on a card, the standee a blockout
254
+ * gets instead of a modelled prop.
255
+ *
256
+ * The card is built base-on-the-floor like every primitive, and sized in
257
+ * metres by the record — `footprint.width` is already derived from the
258
+ * measured height and the picture's aspect, so the geometry never has to do
259
+ * that arithmetic again.
260
+ *
261
+ * Alpha-CUT, not blended: `alphaTest` keeps the card writing depth, which is
262
+ * what lets the ink pass, the shadows and the grey boxes all agree about what
263
+ * is in front of what. A blended card would sort by object and swim through
264
+ * the set.
265
+ *
266
+ * But a bare alpha test is a decision per pixel, so the silhouette comes out
267
+ * as a staircase — and the matte's own soft edge is thrown away at the
268
+ * threshold. `alphaToCoverage` spends the MSAA samples the canvas already has
269
+ * on that edge instead: partial alpha becomes partial coverage, so the outline
270
+ * is resolved by the same antialiasing that smooths every other edge in the
271
+ * frame, and depth is still written. The test then only has to reject what is
272
+ * genuinely nothing (0.15), rather than choosing a side for every half-lit
273
+ * pixel — which is also what keeps a thin structure alive as the card recedes
274
+ * and its alpha is averaged down by the mip chain.
275
+ */
276
+ function Cutout({ object }) {
277
+ const texture = useAssetTexture(object.assetId);
278
+ const width = object.footprint?.width ?? 1;
279
+ const height = object.height ?? 1;
280
+ return (
281
+ <mesh position={[0, height / 2, 0]} castShadow receiveShadow userData={{ cutoutTexture: texture ?? null }}>
282
+ <planeGeometry args={[width, height]} />
283
+ <meshStandardMaterial
284
+ map={texture ?? null}
285
+ color={texture ? object.color : MISSING_CUTOUT}
286
+ // A card seen edge-on is a card, not a hole: both faces draw.
287
+ side={THREE.DoubleSide}
288
+ alphaTest={texture ? 0.15 : 0}
289
+ alphaToCoverage={!!texture}
290
+ roughness={0.92}
291
+ metalness={0}
292
+ />
293
+ </mesh>
294
+ );
295
+ }
296
+
233
297
  const PRIMITIVE_KINDS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "plane"]);
234
298
 
235
- function SceneObjectContent({ renderer, color }) {
299
+ function SceneObjectContent({ object }) {
300
+ const { renderer, color } = object;
301
+ if (renderer === CUTOUT_KIND) return <Cutout object={object} />;
236
302
  if (renderer === "car") return <Car color={color} />;
237
303
  if (renderer === "small-plane") return <SmallPlane />;
238
304
  if (renderer === "chair") return <Chair />;
@@ -284,7 +350,7 @@ function SceneObject({ object, selected }) {
284
350
  // the viewport picker walks up from a hit mesh to find this id
285
351
  userData={{ sceneObjectId: object.id }}
286
352
  >
287
- <SceneObjectContent renderer={object.renderer} color={object.color} />
353
+ <SceneObjectContent object={object} />
288
354
  {selected && <SelectionBox object={object} />}
289
355
  </group>
290
356
  );
package/src/room.jsx CHANGED
@@ -1,51 +1,30 @@
1
1
  /**
2
- * The blocking set: an open room corner.
2
+ * The blocking set: an open stage floor.
3
3
  *
4
- * A blocking frame needs walls far more than it needs a pretty floor. An
5
- * infinite grid gives an AI model no sense of enclosure, so depth, headroom and
6
- * the horizon line all read as ambiguous. Two perpendicular walls and a floor
7
- * preserve that context without enclosing the camera in a box.
4
+ * The set used to be a two-walled room corner, which kept AI blocking frames
5
+ * enclosed but boxed the camera and capped how far a run or a chase could be
6
+ * staged. The walls are gone: the stage is now a near-infinite open deck —
7
+ * large enough that no ordinary blocking ever meets its edge — and enclosure,
8
+ * when a shot needs it, comes from placed set pieces instead of the stage.
8
9
  *
9
- * Dimensions are metres and stage-sized (24 x 24) so a 1.8 m figure has room
10
- * to walk real paths while the corner still reads as an enclosure.
10
+ * Dimensions are metres. The floor is deliberately finite (a plane, not a
11
+ * shader-infinite grid) so exports keep a clean horizon and the framing math
12
+ * never meets an unbounded surface.
11
13
  */
12
14
 
13
- const FLOOR = "#e7e1d7";
14
- const BACK_WALL = "#eef1ed";
15
- const SIDE_WALL = "#e4ecec";
16
- const SKIRTING = "#bdcccc";
15
+ // Bright enough to sit clearly above the grid lines: the deck reads as a lit
16
+ // surface with lines drawn ON it, not as a dark line-texture tiling to the fog.
17
+ const FLOOR = "#f4f0e8";
17
18
 
18
- const SIZE = 24;
19
- const HEIGHT = 6.2;
20
- const BACK_Z = -10;
21
- function Skirting({ position, rotation }) {
22
- return (
23
- <mesh position={position} rotation={rotation}>
24
- <boxGeometry args={[SIZE, 0.16, 0.06]} />
25
- <meshBasicMaterial color={SKIRTING} />
26
- </mesh>
27
- );
28
- }
19
+ export const STAGE_SIZE = 500;
29
20
 
30
21
  export function Room() {
31
22
  return (
32
23
  <group>
33
24
  <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
34
- <planeGeometry args={[SIZE, SIZE]} />
25
+ <planeGeometry args={[STAGE_SIZE, STAGE_SIZE]} />
35
26
  <meshBasicMaterial color={FLOOR} />
36
27
  </mesh>
37
-
38
- <mesh position={[0, HEIGHT / 2, BACK_Z]}>
39
- <planeGeometry args={[SIZE, HEIGHT]} />
40
- <meshBasicMaterial color={BACK_WALL} />
41
- </mesh>
42
- <mesh position={[-SIZE / 2, HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]}>
43
- <planeGeometry args={[SIZE, HEIGHT]} />
44
- <meshBasicMaterial color={SIDE_WALL} />
45
- </mesh>
46
-
47
- <Skirting position={[0, 0.08, BACK_Z + 0.03]} rotation={[0, 0, 0]} />
48
- <Skirting position={[-SIZE / 2 + 0.03, 0.08, 0]} rotation={[0, Math.PI / 2, 0]} />
49
28
  </group>
50
29
  );
51
30
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The bridge between stored asset bytes and a texture the set can wear.
3
+ *
4
+ * A cutout record holds an `assetId` and nothing else, so something has to
5
+ * turn that id into a THREE.Texture exactly once per session: the same picture
6
+ * on two cards, or on cards in two scenes, is one decode and one upload.
7
+ *
8
+ * Loading is async and the renderer is not, so `useAssetTexture` (props.jsx)
9
+ * subscribes and re-renders when the texture lands. A card with no texture yet
10
+ * draws as a blank placeholder rather than popping into existence — the
11
+ * geometry is already correct, and that is what blocking needs first.
12
+ */
13
+
14
+ import * as THREE from "three";
15
+ import { getAsset, openAssetDb, putAsset } from "./scene-assets.js";
16
+
17
+ /** id → { texture, promise, listeners } */
18
+ const entries = new Map();
19
+ let dbPromise = null;
20
+
21
+ function db() {
22
+ if (!dbPromise) dbPromise = openAssetDb();
23
+ return dbPromise;
24
+ }
25
+
26
+ function entryFor(id) {
27
+ let entry = entries.get(id);
28
+ if (!entry) {
29
+ entry = { texture: null, record: null, promise: null, listeners: new Set() };
30
+ entries.set(id, entry);
31
+ }
32
+ return entry;
33
+ }
34
+
35
+ function announce(entry) {
36
+ for (const listener of entry.listeners) listener(entry.texture);
37
+ }
38
+
39
+ /**
40
+ * A texture from asset bytes. ImageBitmap ignores `Texture.flipY`, so the flip
41
+ * is asked of the decoder instead — without it every cutout hangs upside down.
42
+ */
43
+ async function textureFromAsset(asset) {
44
+ const bitmap = await createImageBitmap(new Blob([asset.bytes], { type: asset.type }), { imageOrientation: "flipY" });
45
+ const texture = new THREE.Texture(bitmap);
46
+ texture.flipY = false;
47
+ texture.colorSpace = THREE.SRGBColorSpace;
48
+ texture.generateMipmaps = true;
49
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
50
+ texture.magFilter = THREE.LinearFilter;
51
+ // A card is nearly always seen at an angle; without anisotropy the picture
52
+ // smears the moment the camera is off its normal.
53
+ texture.anisotropy = 4;
54
+ texture.needsUpdate = true;
55
+ texture.userData.assetId = asset.id;
56
+ return texture;
57
+ }
58
+
59
+ /** The texture for this id, decoded once and shared. Null when the asset is
60
+ * gone — a scene can outlive its pictures (another browser, cleared storage),
61
+ * and a missing picture must not take the studio down with it. */
62
+ export function loadAssetTexture(id) {
63
+ if (typeof id !== "string" || !id) return Promise.resolve(null);
64
+ const entry = entryFor(id);
65
+ if (entry.texture) return Promise.resolve(entry.texture);
66
+ if (!entry.promise) {
67
+ entry.promise = (async () => {
68
+ const asset = entry.record ?? (await getAsset(await db(), id));
69
+ if (!asset) return null;
70
+ entry.record = asset;
71
+ entry.texture = await textureFromAsset(asset);
72
+ announce(entry);
73
+ return entry.texture;
74
+ })().catch((error) => {
75
+ console.warn(`[cozyclay] could not load image asset ${id}`, error);
76
+ return null;
77
+ });
78
+ }
79
+ return entry.promise;
80
+ }
81
+
82
+ /** The stored record behind an id — the bytes, not the texture. Editing a
83
+ * picture (cutting its background out) needs the source it started from. */
84
+ export async function assetRecord(id) {
85
+ if (typeof id !== "string" || !id) return null;
86
+ const entry = entryFor(id);
87
+ if (entry.record) return entry.record;
88
+ entry.record = await getAsset(await db(), id);
89
+ return entry.record;
90
+ }
91
+
92
+ /** Warm the cache with an asset that is already in hand. An import has the
93
+ * decoded bytes right there; going back to IndexedDB for them would decode the
94
+ * same picture twice and delay the card by a frame or two. */
95
+ export async function rememberAsset(asset) {
96
+ const stored = await putAsset(await db(), asset);
97
+ const entry = entryFor(stored.id);
98
+ entry.record = stored;
99
+ entry.texture?.dispose();
100
+ entry.texture = await textureFromAsset(stored);
101
+ entry.promise = Promise.resolve(entry.texture);
102
+ announce(entry);
103
+ return stored;
104
+ }
105
+
106
+ /** Subscribe to one id; returns an unsubscribe. The listener fires
107
+ * immediately when the texture is already cached, so a mount never waits a
108
+ * frame for something that is in memory. */
109
+ export function subscribeToAssetTexture(id, listener) {
110
+ if (typeof id !== "string" || !id) return () => {};
111
+ const entry = entryFor(id);
112
+ entry.listeners.add(listener);
113
+ if (entry.texture) listener(entry.texture);
114
+ else loadAssetTexture(id);
115
+ return () => entry.listeners.delete(listener);
116
+ }
117
+
118
+ /** Drop everything (a test harness, or a hard document reload). */
119
+ export function clearAssetTextures() {
120
+ for (const entry of entries.values()) {
121
+ entry.texture?.image?.close?.();
122
+ entry.texture?.dispose();
123
+ }
124
+ entries.clear();
125
+ }