@call-me-sensei/toonlab 0.4.19 → 0.4.21

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 (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -27,25 +27,37 @@ function normalizeEntry(entry) {
27
27
  };
28
28
  }
29
29
 
30
- export async function databaseInfo() {
31
- const pool = await getPool();
32
- const result = await pool.query(
33
- `select
34
- (select count(*)::int from creations) as creation_count,
35
- (select count(*)::int from catalog_assets) as catalog_count`,
36
- );
37
- return result.rows[0];
30
+ function revisionRow(row) {
31
+ if (!row) return null;
32
+ return {
33
+ id: row.id,
34
+ creationId: row.creation_id,
35
+ number: row.revision_number,
36
+ label: row.label,
37
+ description: row.description,
38
+ tags: row.tags ?? [],
39
+ document: row.document,
40
+ aiGenerated: row.ai_generated,
41
+ contentHash: row.content_hash,
42
+ saveSource: row.save_source,
43
+ dependencies: row.dependency_snapshot ?? {},
44
+ restoredFromRevisionId: row.restored_from_revision_id,
45
+ name: row.version_name,
46
+ versionTags: row.version_tags ?? [],
47
+ note: row.version_note,
48
+ pinned: row.pinned,
49
+ createdAt: row.created_at,
50
+ annotationUpdatedAt: row.annotation_updated_at,
51
+ };
38
52
  }
39
53
 
40
- export async function listLibraryEntries() {
41
- const pool = await getPool();
42
- const result = await pool.query(
43
- `select id, doc_key, type, label, description, tags, document, source,
44
- ai_generated, created_at, updated_at
45
- from creations
46
- order by updated_at desc, label`,
47
- );
48
- return result.rows.map((row) => ({
54
+ function revisionSummaryRow(row) {
55
+ const { document: _document, dependencies: _dependencies, ...summary } = revisionRow(row);
56
+ return summary;
57
+ }
58
+
59
+ function creationRowToEntry(row) {
60
+ return {
49
61
  ...row.document,
50
62
  id: row.doc_key,
51
63
  type: row.type,
@@ -55,56 +67,392 @@ export async function listLibraryEntries() {
55
67
  aiGenerated: row.ai_generated || row.document?.aiGenerated === true,
56
68
  _local: {
57
69
  creationId: row.id,
70
+ currentRevisionId: row.current_revision_id,
71
+ dependencySnapshot: row.current_dependency_snapshot ?? {},
58
72
  source: row.source,
59
73
  createdAt: row.created_at,
60
74
  updatedAt: row.updated_at,
61
75
  },
76
+ };
77
+ }
78
+
79
+ async function styleBundleDependencySnapshot(client, document) {
80
+ const slots = document?.slots;
81
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) return {};
82
+ const references = Object.entries(slots).flatMap(([slotId, payload]) => {
83
+ const reference = payload && typeof payload === 'object' ? String(payload.creation ?? '').trim() : '';
84
+ return reference ? [{ reference, slotId }] : [];
85
+ });
86
+ if (!references.length) return {};
87
+ const values = [...new Set(references.map(({ reference }) => reference))];
88
+ const result = await client.query(
89
+ `select id, doc_key, current_revision_id
90
+ from creations
91
+ where id::text = any($1::text[]) or doc_key = any($1::text[])`,
92
+ [values],
93
+ );
94
+ const byReference = new Map();
95
+ for (const reference of values) {
96
+ const exactId = result.rows.find((row) => row.id === reference);
97
+ const matches = exactId ? [exactId] : result.rows.filter((row) => row.doc_key === reference);
98
+ if (matches.length > 1) {
99
+ throw Object.assign(
100
+ new Error(`Style bundle reference "${reference}" is ambiguous; use the creation UUID.`),
101
+ { statusCode: 409 },
102
+ );
103
+ }
104
+ if (matches[0]) byReference.set(reference, matches[0]);
105
+ }
106
+ const missing = references.filter(({ reference }) => !byReference.get(reference)?.current_revision_id);
107
+ if (missing.length) {
108
+ throw Object.assign(
109
+ new Error(`Style bundle references must resolve to saved creations with version history: ${[...new Set(missing.map(({ reference }) => reference))].join(', ')}`),
110
+ { statusCode: 409 },
111
+ );
112
+ }
113
+ return Object.fromEntries(references.map(({ reference, slotId }) => {
114
+ const row = byReference.get(reference);
115
+ return [slotId, { creationId: row.id, revisionId: row.current_revision_id }];
62
116
  }));
63
117
  }
64
118
 
65
- export async function saveLibraryEntry(entry) {
66
- const value = normalizeEntry(entry);
67
- const pool = await getPool();
68
- const saved = await pool.query(
69
- `insert into creations (doc_key, type, label, description, tags, document, ai_generated)
70
- values ($1, $2, $3, $4, $5, $6::jsonb, $7)
71
- on conflict (type, doc_key) do update set
72
- label = excluded.label,
73
- description = excluded.description,
74
- tags = excluded.tags,
75
- document = excluded.document,
76
- ai_generated = creations.ai_generated or excluded.ai_generated,
119
+ async function commitCreationRevision(client, creation, {
120
+ dependencies,
121
+ force = false,
122
+ restoredFromRevisionId = null,
123
+ saveSource = 'manual',
124
+ } = {}) {
125
+ const dependencySnapshot = dependencies
126
+ ?? (creation.type === 'style-bundle'
127
+ ? await styleBundleDependencySnapshot(client, creation.document)
128
+ : {});
129
+ const locked = await client.query(
130
+ `select c.current_revision_id, r.content_hash
131
+ from creations c
132
+ left join creation_revisions r on r.id = c.current_revision_id
133
+ where c.id = $1
134
+ for update of c`,
135
+ [creation.id],
136
+ );
137
+ if (!locked.rows[0]) throw Object.assign(new Error('Creation not found.'), { statusCode: 404 });
138
+ const digest = await client.query(
139
+ `select encode(digest(convert_to(jsonb_build_object(
140
+ 'label', $1::text,
141
+ 'description', $2::text,
142
+ 'tags', $3::text[],
143
+ 'document', $4::jsonb,
144
+ 'aiGenerated', $5::boolean,
145
+ 'dependencies', $6::jsonb
146
+ )::text, 'UTF8'), 'sha256'), 'hex') as value`,
147
+ [
148
+ creation.label,
149
+ creation.description,
150
+ creation.tags,
151
+ JSON.stringify(creation.document),
152
+ creation.aiGenerated,
153
+ JSON.stringify(dependencySnapshot),
154
+ ],
155
+ );
156
+ const contentHash = digest.rows[0].value;
157
+ if (!force && locked.rows[0].content_hash === contentHash) {
158
+ return { created: false, id: locked.rows[0].current_revision_id };
159
+ }
160
+ const inserted = await client.query(
161
+ `insert into creation_revisions (
162
+ creation_id, revision_number, label, description, tags, document,
163
+ ai_generated, content_hash, save_source, dependency_snapshot,
164
+ restored_from_revision_id
165
+ )
166
+ values (
167
+ $1,
168
+ coalesce((select max(revision_number) + 1 from creation_revisions where creation_id = $1), 1),
169
+ $2, $3, $4, $5::jsonb, $6, $7, $8, $9::jsonb, $10
170
+ )
171
+ returning id`,
172
+ [
173
+ creation.id,
174
+ creation.label,
175
+ creation.description,
176
+ creation.tags,
177
+ JSON.stringify(creation.document),
178
+ creation.aiGenerated,
179
+ contentHash,
180
+ saveSource,
181
+ JSON.stringify(dependencySnapshot),
182
+ restoredFromRevisionId,
183
+ ],
184
+ );
185
+ await client.query(
186
+ `update creations set
187
+ label = $2,
188
+ description = $3,
189
+ tags = $4,
190
+ document = $5::jsonb,
191
+ ai_generated = $6,
192
+ current_revision_id = $7,
77
193
  updated_at = now()
78
- returning id, source, created_at, updated_at`,
194
+ where id = $1`,
79
195
  [
80
- value.docKey,
81
- value.type,
82
- value.label,
83
- value.description,
84
- value.tags,
85
- JSON.stringify(value.document),
86
- value.aiGenerated,
196
+ creation.id,
197
+ creation.label,
198
+ creation.description,
199
+ creation.tags,
200
+ JSON.stringify(creation.document),
201
+ creation.aiGenerated,
202
+ inserted.rows[0].id,
87
203
  ],
88
204
  );
89
- const fileId = entry?.result?.file?.id ?? entry?.file?.id ?? null;
90
- if (fileId) {
91
- await pool.query(
92
- `update files set creation_id = $1
93
- where id = $2 and creation_id is null`,
94
- [saved.rows[0].id, fileId],
205
+ return { created: true, id: inserted.rows[0].id };
206
+ }
207
+
208
+ export async function databaseInfo() {
209
+ const pool = await getPool();
210
+ const result = await pool.query(
211
+ `select
212
+ (select count(*)::int from creations) as creation_count,
213
+ (select count(*)::int from catalog_assets) as catalog_count`,
214
+ );
215
+ return result.rows[0];
216
+ }
217
+
218
+ export async function listLibraryEntries() {
219
+ const pool = await getPool();
220
+ const result = await pool.query(
221
+ `select c.id, c.doc_key, c.type, c.label, c.description, c.tags, c.document, c.source,
222
+ c.ai_generated, c.current_revision_id, c.created_at, c.updated_at,
223
+ r.dependency_snapshot as current_dependency_snapshot
224
+ from creations c
225
+ left join creation_revisions r on r.id = c.current_revision_id
226
+ order by c.updated_at desc, c.label`,
227
+ );
228
+ return result.rows.map(creationRowToEntry);
229
+ }
230
+
231
+ export async function saveLibraryEntry(entry) {
232
+ const value = normalizeEntry(entry);
233
+ return withTransaction(async (client) => {
234
+ const existing = await client.query(
235
+ `select * from creations where type = $1 and doc_key = $2 for update`,
236
+ [value.type, value.docKey],
95
237
  );
96
- }
238
+ let row = existing.rows[0];
239
+ if (!row) {
240
+ const inserted = await client.query(
241
+ `insert into creations (doc_key, type, label, description, tags, document, ai_generated)
242
+ values ($1, $2, $3, $4, $5, $6::jsonb, $7)
243
+ returning *`,
244
+ [
245
+ value.docKey,
246
+ value.type,
247
+ value.label,
248
+ value.description,
249
+ value.tags,
250
+ JSON.stringify(value.document),
251
+ value.aiGenerated,
252
+ ],
253
+ );
254
+ row = inserted.rows[0];
255
+ }
256
+ const snapshot = {
257
+ ...row,
258
+ label: value.label,
259
+ description: value.description,
260
+ tags: value.tags,
261
+ document: value.document,
262
+ aiGenerated: row.ai_generated || value.aiGenerated,
263
+ };
264
+ const revision = await commitCreationRevision(client, {
265
+ ...snapshot,
266
+ aiGenerated: snapshot.aiGenerated,
267
+ });
268
+ const fileId = entry?.result?.file?.id ?? entry?.file?.id ?? null;
269
+ if (fileId) {
270
+ await client.query(
271
+ `update files set creation_id = $1
272
+ where id = $2 and creation_id is null`,
273
+ [row.id, fileId],
274
+ );
275
+ }
276
+ const saved = await client.query(
277
+ `select c.*, r.dependency_snapshot as current_dependency_snapshot
278
+ from creations c
279
+ left join creation_revisions r on r.id = c.current_revision_id
280
+ where c.id = $1`,
281
+ [row.id],
282
+ );
283
+ return {
284
+ ...creationRowToEntry(saved.rows[0]),
285
+ _local: {
286
+ ...creationRowToEntry(saved.rows[0])._local,
287
+ revisionCreated: revision.created,
288
+ },
289
+ };
290
+ });
291
+ }
292
+
293
+ export async function listCreationRevisions(creationId, { limit = 25, offset = 0 } = {}) {
294
+ const boundedLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 25)));
295
+ const boundedOffset = Math.max(0, Math.floor(Number(offset) || 0));
296
+ const pool = await getPool();
297
+ const [result, countResult] = await Promise.all([
298
+ pool.query(
299
+ `select
300
+ r.id, r.creation_id, r.revision_number, r.label, r.description, r.tags,
301
+ r.ai_generated, r.content_hash, r.save_source, r.restored_from_revision_id,
302
+ r.version_name, r.version_tags, r.version_note, r.pinned,
303
+ r.annotation_updated_at, r.created_at,
304
+ (r.id = c.current_revision_id) as is_current
305
+ from creation_revisions r
306
+ join creations c on c.id = r.creation_id
307
+ where r.creation_id = $1
308
+ order by r.pinned desc, r.revision_number desc
309
+ limit $2 offset $3`,
310
+ [creationId, boundedLimit, boundedOffset],
311
+ ),
312
+ pool.query(
313
+ `select count(*)::int as total from creation_revisions where creation_id = $1`,
314
+ [creationId],
315
+ ),
316
+ ]);
97
317
  return {
98
- ...value.document,
99
- _local: {
100
- creationId: saved.rows[0].id,
101
- source: saved.rows[0].source,
102
- createdAt: saved.rows[0].created_at,
103
- updatedAt: saved.rows[0].updated_at,
104
- },
318
+ revisions: result.rows.map((row) => ({ ...revisionSummaryRow(row), isCurrent: row.is_current })),
319
+ total: countResult.rows[0]?.total ?? 0,
105
320
  };
106
321
  }
107
322
 
323
+ export async function getCreationRevision(creationId, revisionId) {
324
+ const pool = await getPool();
325
+ const result = await pool.query(
326
+ `select r.*, (r.id = c.current_revision_id) as is_current
327
+ from creation_revisions r
328
+ join creations c on c.id = r.creation_id
329
+ where r.creation_id = $1 and r.id = $2`,
330
+ [creationId, revisionId],
331
+ );
332
+ if (!result.rows[0]) throw Object.assign(new Error('Revision not found.'), { statusCode: 404 });
333
+ return { ...revisionRow(result.rows[0]), isCurrent: result.rows[0].is_current };
334
+ }
335
+
336
+ export async function resolveStyleBundleEntry(selector) {
337
+ const value = String(selector ?? '').trim();
338
+ const pool = await getPool();
339
+ const result = await pool.query(
340
+ `select c.*, r.dependency_snapshot
341
+ from creations c
342
+ join creation_revisions r on r.id = c.current_revision_id
343
+ where c.type = 'style-bundle' and (c.id::text = $1 or c.doc_key = $1)
344
+ order by (c.id::text = $1) desc
345
+ limit 1`,
346
+ [value],
347
+ );
348
+ const bundle = result.rows[0];
349
+ if (!bundle) throw Object.assign(new Error('Style bundle not found.'), { statusCode: 404 });
350
+ const slots = { ...(bundle.document?.slots ?? {}) };
351
+ const dependencies = bundle.dependency_snapshot ?? {};
352
+ for (const [slotId, payload] of Object.entries(slots)) {
353
+ if (!payload?.creation) continue;
354
+ const lock = dependencies[slotId];
355
+ if (!lock?.creationId || !lock?.revisionId) {
356
+ throw Object.assign(new Error(`Style bundle slot "${slotId}" has no locked dependency revision.`), { statusCode: 409 });
357
+ }
358
+ const locked = await pool.query(
359
+ `select r.document, c.doc_key
360
+ from creation_revisions r
361
+ join creations c on c.id = r.creation_id
362
+ where r.id = $1 and r.creation_id = $2`,
363
+ [lock.revisionId, lock.creationId],
364
+ );
365
+ if (
366
+ !locked.rows[0]
367
+ || (payload.creation !== lock.creationId && payload.creation !== locked.rows[0].doc_key)
368
+ ) {
369
+ throw Object.assign(new Error(`Locked dependency for style bundle slot "${slotId}" is unavailable.`), { statusCode: 409 });
370
+ }
371
+ slots[slotId] = { document: locked.rows[0].document };
372
+ }
373
+ return { ...bundle.document, slots };
374
+ }
375
+
376
+ export async function annotateCreationRevision(creationId, revisionId, patch) {
377
+ const assignments = [];
378
+ const values = [creationId, revisionId];
379
+ const assign = (column, value) => {
380
+ values.push(value);
381
+ assignments.push(`${column} = $${values.length}`);
382
+ };
383
+ if (Object.hasOwn(patch, 'name')) {
384
+ assign(
385
+ 'version_name',
386
+ patch.name == null || !String(patch.name).trim()
387
+ ? null
388
+ : String(patch.name).trim().slice(0, 120),
389
+ );
390
+ }
391
+ if (Object.hasOwn(patch, 'tags')) assign('version_tags', normalizeCreationTags(patch.tags));
392
+ if (Object.hasOwn(patch, 'note')) {
393
+ assign(
394
+ 'version_note',
395
+ patch.note == null || !String(patch.note).trim()
396
+ ? null
397
+ : String(patch.note).trim().slice(0, 2000),
398
+ );
399
+ }
400
+ if (Object.hasOwn(patch, 'pinned')) assign('pinned', patch.pinned === true);
401
+ assignments.push('annotation_updated_at = now()');
402
+ const pool = await getPool();
403
+ try {
404
+ const result = await pool.query(
405
+ `update creation_revisions set
406
+ ${assignments.join(', ')}
407
+ where creation_id = $1 and id = $2
408
+ returning *`,
409
+ values,
410
+ );
411
+ if (!result.rows[0]) throw Object.assign(new Error('Revision not found.'), { statusCode: 404 });
412
+ return revisionRow(result.rows[0]);
413
+ } catch (error) {
414
+ if (error?.code === '23505') {
415
+ throw Object.assign(new Error('Version names must be unique within a creation.'), { statusCode: 409 });
416
+ }
417
+ throw error;
418
+ }
419
+ }
420
+
421
+ export async function restoreCreationRevision(creationId, revisionId) {
422
+ return withTransaction(async (client) => {
423
+ const target = await client.query(
424
+ `select r.*, c.doc_key, c.type, c.source, c.created_at
425
+ from creation_revisions r
426
+ join creations c on c.id = r.creation_id
427
+ where r.creation_id = $1 and r.id = $2`,
428
+ [creationId, revisionId],
429
+ );
430
+ const revision = target.rows[0];
431
+ if (!revision) throw Object.assign(new Error('Revision not found.'), { statusCode: 404 });
432
+ await commitCreationRevision(client, {
433
+ aiGenerated: revision.ai_generated,
434
+ description: revision.description,
435
+ document: revision.document,
436
+ id: creationId,
437
+ label: revision.label,
438
+ tags: revision.tags,
439
+ }, {
440
+ dependencies: revision.dependency_snapshot,
441
+ force: true,
442
+ restoredFromRevisionId: revisionId,
443
+ saveSource: 'restore',
444
+ });
445
+ const saved = await client.query(
446
+ `select c.*, r.dependency_snapshot as current_dependency_snapshot
447
+ from creations c
448
+ left join creation_revisions r on r.id = c.current_revision_id
449
+ where c.id = $1`,
450
+ [creationId],
451
+ );
452
+ return creationRowToEntry(saved.rows[0]);
453
+ });
454
+ }
455
+
108
456
  export function resolveUnambiguousLibraryDeletion(matches, docKey) {
109
457
  if (matches.length > 1) {
110
458
  throw new Error(
@@ -200,13 +548,8 @@ async function setLabStateWithClient(client, key, value) {
200
548
  );
201
549
  }
202
550
  if (!/\.(?:presets|projects)(?:\.|$)/.test(stateKey)) return;
203
- await client.query(
204
- `delete from creations
205
- where source = 'lab-state'
206
- and left(doc_key, char_length($1)) = $1`,
207
- [`state:${stateKey}:`],
208
- );
209
551
  const storedEntries = extractNamedLabStateDocuments(parsed);
552
+ const retainedDocKeys = [];
210
553
  for (const { document, storeKey } of storedEntries) {
211
554
  if (!document || typeof document !== 'object' || Array.isArray(document)) continue;
212
555
  const identity = String(
@@ -220,6 +563,7 @@ async function setLabStateWithClient(client, key, value) {
220
563
  // in lab_state/lab_drafts, not in the Library.
221
564
  if (!identity || !explicitLabel || identity.startsWith('__')) continue;
222
565
  const docKey = `state:${stateKey}:${identity}`;
566
+ retainedDocKeys.push(docKey);
223
567
  const type = String(document.type ?? `${labId}-${stateKey.includes('.projects') ? 'project' : 'preset'}`);
224
568
  const label = explicitLabel.slice(0, 120);
225
569
  const entry = {
@@ -230,17 +574,55 @@ async function setLabStateWithClient(client, key, value) {
230
574
  tags: normalizeCreationTags(document.tags),
231
575
  type,
232
576
  };
233
- await client.query(
234
- `insert into creations (doc_key, type, label, tags, document, source)
235
- values ($1, $2, $3, $4, $5::jsonb, 'lab-state')
236
- on conflict (type, doc_key) do update set
237
- label = excluded.label,
238
- tags = excluded.tags,
239
- document = excluded.document,
240
- updated_at = now()`,
241
- [docKey, type, label, entry.tags, JSON.stringify(entry)],
577
+ // The doc key is the stable identity. Preserve its creation id and history
578
+ // even if a document-schema migration changes the type discriminator.
579
+ const matches = await client.query(
580
+ `select * from creations
581
+ where source = 'lab-state' and doc_key = $1
582
+ order by (type = $2) desc, updated_at desc
583
+ for update`,
584
+ [docKey, type],
242
585
  );
586
+ let saved;
587
+ if (matches.rows.length) {
588
+ const keeper = matches.rows[0];
589
+ if (matches.rows.length > 1) {
590
+ await client.query(
591
+ `delete from creations where id = any($1::uuid[])`,
592
+ [matches.rows.slice(1).map((row) => row.id)],
593
+ );
594
+ }
595
+ saved = await client.query(
596
+ `update creations set
597
+ type = $2,
598
+ label = $3,
599
+ tags = $4,
600
+ document = $5::jsonb,
601
+ updated_at = now()
602
+ where id = $1
603
+ returning *`,
604
+ [keeper.id, type, label, entry.tags, JSON.stringify(entry)],
605
+ );
606
+ } else {
607
+ saved = await client.query(
608
+ `insert into creations (doc_key, type, label, tags, document, source)
609
+ values ($1, $2, $3, $4, $5::jsonb, 'lab-state')
610
+ returning *`,
611
+ [docKey, type, label, entry.tags, JSON.stringify(entry)],
612
+ );
613
+ }
614
+ await commitCreationRevision(client, {
615
+ ...saved.rows[0],
616
+ aiGenerated: saved.rows[0].ai_generated,
617
+ }, { saveSource: 'lab-state' });
243
618
  }
619
+ await client.query(
620
+ `delete from creations
621
+ where source = 'lab-state'
622
+ and left(doc_key, char_length($1)) = $1
623
+ and not (doc_key = any($2::text[]))`,
624
+ [`state:${stateKey}:`, retainedDocKeys],
625
+ );
244
626
  }
245
627
 
246
628
  export async function setLabState(key, value) {
@@ -302,11 +684,17 @@ export async function migrateLegacy({
302
684
  const insert = await client.query(
303
685
  `insert into creations (doc_key, type, label, description, document, ai_generated)
304
686
  values ($1, $2, $3, $4, $5::jsonb, $6)
305
- on conflict (type, doc_key) do nothing`,
687
+ on conflict (type, doc_key) do nothing
688
+ returning *`,
306
689
  [value.docKey, value.type, value.label, value.description, JSON.stringify(entry), value.aiGenerated],
307
690
  );
308
- if (insert.rowCount) imported += 1;
309
- else skipped += 1;
691
+ if (insert.rowCount) {
692
+ await commitCreationRevision(client, {
693
+ ...insert.rows[0],
694
+ aiGenerated: insert.rows[0].ai_generated,
695
+ }, { saveSource: 'legacy-import' });
696
+ imported += 1;
697
+ } else skipped += 1;
310
698
  } catch (error) {
311
699
  failures.push({ id: entry?.id ?? null, error: error.message });
312
700
  }
@@ -336,7 +724,15 @@ export async function migrateLegacy({
336
724
  return report;
337
725
  }
338
726
 
339
- export async function listCatalogAssets({ q = '', kind = '', source = '', limit = 60, offset = 0 } = {}) {
727
+ export async function listCatalogAssets({
728
+ q = '',
729
+ kind = '',
730
+ source = '',
731
+ license = '',
732
+ size = '',
733
+ limit = 60,
734
+ offset = 0,
735
+ } = {}) {
340
736
  const pool = await getPool();
341
737
  const values = [];
342
738
  const where = [`availability_status = 'active'`];
@@ -356,6 +752,14 @@ export async function listCatalogAssets({ q = '', kind = '', source = '', limit
356
752
  values.push(source);
357
753
  where.push(`source = $${values.length}`);
358
754
  }
755
+ if (license) {
756
+ values.push(license);
757
+ where.push(`license = $${values.length}`);
758
+ }
759
+ if (size) {
760
+ values.push(size);
761
+ where.push(`metadata->>'catalog' = 'rocks' and $${values.length} = any(tags)`);
762
+ }
359
763
  values.push(Math.min(Math.max(Number(limit) || 60, 1), 500));
360
764
  const limitIndex = values.length;
361
765
  values.push(Math.max(Number(offset) || 0, 0));
@@ -374,6 +778,38 @@ export async function listCatalogAssets({ q = '', kind = '', source = '', limit
374
778
  };
375
779
  }
376
780
 
781
+ export async function listCatalogFacets() {
782
+ const pool = await getPool();
783
+ const [sources, licenses, kinds] = await Promise.all([
784
+ pool.query(
785
+ `select source, license, count(*)::int as count
786
+ from catalog_assets
787
+ where availability_status = 'active'
788
+ group by source, license
789
+ order by source, license`,
790
+ ),
791
+ pool.query(
792
+ `select license, count(*)::int as count
793
+ from catalog_assets
794
+ where availability_status = 'active'
795
+ group by license
796
+ order by license`,
797
+ ),
798
+ pool.query(
799
+ `select kind, count(*)::int as count
800
+ from catalog_assets
801
+ where availability_status = 'active'
802
+ group by kind
803
+ order by kind`,
804
+ ),
805
+ ]);
806
+ return {
807
+ sources: sources.rows,
808
+ licenses: licenses.rows,
809
+ kinds: kinds.rows,
810
+ };
811
+ }
812
+
377
813
  export async function getCatalogAsset(id) {
378
814
  const pool = await getPool();
379
815
  const asset = await pool.query(
@@ -490,6 +926,7 @@ export function providerConfiguration() {
490
926
  gemini: Boolean(process.env.GEMINI_API_KEY || process.env.GEMINI_API_KEYS),
491
927
  openai: Boolean(process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEYS),
492
928
  ark: Boolean(process.env.ARK_API_KEY || process.env.ARK_API_KEYS),
929
+ fal: Boolean(process.env.FAL_KEY || process.env.FAL_KEYS),
493
930
  polypizza: Boolean(process.env.POLYPIZZA_API_KEY),
494
931
  };
495
932
  return {
@@ -524,6 +961,17 @@ export function providerConfiguration() {
524
961
  resolutions: ['1k', '2k', '4k'],
525
962
  },
526
963
  ],
964
+ materialModels: [
965
+ {
966
+ configured: providers.fal,
967
+ id: 'patina',
968
+ label: 'Patina PBR Material',
969
+ maps: ['basecolor', 'normal', 'roughness', 'metalness', 'height'],
970
+ model: 'fal-ai/patina/material',
971
+ provider: 'fal',
972
+ resolutions: ['1k', '2k', '4k', '8k'],
973
+ },
974
+ ],
527
975
  promptEnhancementConfigured: providers.gemini || providers.openai,
528
976
  providers,
529
977
  styles: [