@amg555/frameforge-mcp 0.0.0-stage → 2.3.1

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 (51) hide show
  1. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
  2. package/.claude-plugin/marketplace.json +45 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +447 -0
  6. package/CLAUDE.md +320 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +377 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +200 -0
  13. package/assets/metadata/assets.json +5 -0
  14. package/assets/metadata/embeddings.json +5 -0
  15. package/install.sh +64 -0
  16. package/package.json +58 -4
  17. package/src/animation/animation-system.js +229 -0
  18. package/src/asset-library/README.md +188 -0
  19. package/src/asset-library/asset-library.js +639 -0
  20. package/src/audio-mixer/mixer.js +187 -0
  21. package/src/cli/doctor.js +173 -0
  22. package/src/cli/make.js +655 -0
  23. package/src/cli/setup-keys.js +392 -0
  24. package/src/config/load-config.js +72 -0
  25. package/src/fact-checker/checker.js +384 -0
  26. package/src/fact-checker/review-gate.js +306 -0
  27. package/src/fact-checker/validation-set.js +1078 -0
  28. package/src/mcp-server/server.js +1299 -0
  29. package/src/mcp-server/stdio.js +87 -0
  30. package/src/pipeline/artifact-manifest.js +323 -0
  31. package/src/pipeline/artifact-retention.js +189 -0
  32. package/src/pipeline/external-adapter.js +366 -0
  33. package/src/pipeline/providers.js +853 -0
  34. package/src/pipeline/retry-policy.js +134 -0
  35. package/src/pipeline/state-machine.js +297 -0
  36. package/src/pipeline/telemetry.js +162 -0
  37. package/src/quality-gate/gate.js +229 -0
  38. package/src/schema/scene.schema.json +101 -0
  39. package/src/shorts-extractor/extractor.js +343 -0
  40. package/src/utils/log-redaction.js +274 -0
  41. package/src/video-renderer/renderer.js +364 -0
  42. package/src/worker/server-render-free.js +420 -0
  43. package/src/worker/server.js +328 -0
  44. package/src/workflows/assets.js +131 -0
  45. package/src/workflows/ingest.js +299 -0
  46. package/src/workflows/packaging.js +156 -0
  47. package/src/workflows/publishing.js +206 -0
  48. package/src/workflows/render.js +102 -0
  49. package/src/workflows/research.js +151 -0
  50. package/src/workflows/script.js +210 -0
  51. package/src/workflows/shorts.js +113 -0
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Asset Library - Composable Asset Management System
3
+ *
4
+ * Manages reusable visual assets with consistent style for video production.
5
+ * Uses composable character system: identity + pose + emotion
6
+ *
7
+ * Architecture:
8
+ * - Metadata filtering (primary): Fast PostgreSQL lookup
9
+ * - Semantic search (fallback): Embedding similarity if no metadata match
10
+ * - Generation: Pollinations API with style consistency
11
+ */
12
+
13
+ const fs = require('fs').promises;
14
+ const fsSync = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const https = require('https');
18
+ const http = require('http');
19
+
20
+ class AssetLibrary {
21
+ constructor(config = {}) {
22
+ this.assetsDir = config.assetsDir || path.join(__dirname, '../../assets');
23
+ this.metadataFile = path.join(this.assetsDir, 'metadata', 'assets.json');
24
+ this.embeddingsFile = path.join(this.assetsDir, 'metadata', 'embeddings.json');
25
+
26
+ this.assets = new Map();
27
+ this.embeddings = new Map();
28
+ this.initialized = false;
29
+
30
+ // Style parameters for consistency
31
+ this.styleParams = {
32
+ ink_style: 'clean_line',
33
+ color_palette: ['#000000', '#FFFFFF', '#8B4513', '#D2691E'],
34
+ line_weight: 3,
35
+ resolution: '1920x1080'
36
+ };
37
+
38
+ // Semantic similarity threshold
39
+ this.similarityThreshold = config.similarityThreshold || 0.85;
40
+
41
+ // Asset generation timeout
42
+ this.generationTimeout = config.generationTimeout || 180000; // 3 minutes
43
+ }
44
+
45
+ /**
46
+ * Initialize asset library - Load registry from disk
47
+ */
48
+ async initialize() {
49
+ if (this.initialized) return;
50
+
51
+ console.log('🎨 Initializing Asset Library...');
52
+ const startTime = Date.now();
53
+
54
+ try {
55
+ // Ensure directories exist
56
+ await this._ensureDirectories();
57
+
58
+ // Load asset registry
59
+ await this._loadAssetRegistry();
60
+
61
+ // Load embeddings cache
62
+ await this._loadEmbeddings();
63
+
64
+ const loadTime = Date.now() - startTime;
65
+
66
+ if (loadTime > 10000) {
67
+ console.warn(`⚠️ Asset library loaded in ${loadTime}ms (>10s threshold)`);
68
+ } else {
69
+ console.log(`✅ Asset library loaded: ${this.assets.size} assets in ${loadTime}ms`);
70
+ }
71
+
72
+ this.initialized = true;
73
+ } catch (error) {
74
+ console.error('❌ Failed to initialize asset library:', error.message);
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Find asset by query (metadata filtering + semantic fallback)
81
+ *
82
+ * @param {Object} query - Asset query
83
+ * @param {string} query.asset_type - 'character_identity', 'pose', 'emotion', 'prop', 'background', 'effect'
84
+ * @param {string} [query.character_id] - Character identifier
85
+ * @param {string} [query.pose] - Pose variant
86
+ * @param {string} [query.emotion] - Emotion overlay
87
+ * @param {string} [query.visual_description] - Fallback semantic search
88
+ * @returns {Promise<Asset|null>}
89
+ */
90
+ async findAsset(query) {
91
+ if (!this.initialized) await this.initialize();
92
+
93
+ console.log('🔍 Finding asset:', query);
94
+
95
+ // Step 1: Metadata filtering (primary)
96
+ const metadataMatches = this._filterByMetadata(query);
97
+
98
+ if (metadataMatches.length > 0) {
99
+ console.log(`✅ Found ${metadataMatches.length} metadata match(es)`);
100
+ return metadataMatches[0]; // Return best match
101
+ }
102
+
103
+ // Step 2: Semantic search (fallback)
104
+ if (query.visual_description) {
105
+ console.log('🔍 Trying semantic fallback...');
106
+ const semanticMatch = await this._semanticSearch(query.visual_description);
107
+
108
+ // _semanticSearch returns the asset directly (not a {similarity, asset} wrapper)
109
+ if (semanticMatch) {
110
+ console.log(`✅ Found semantic match`);
111
+ return semanticMatch;
112
+ }
113
+ }
114
+
115
+ console.log('❌ No matching asset found');
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Generate new asset with style consistency
121
+ *
122
+ * @param {Object} description - Asset description
123
+ * @param {string} description.asset_type
124
+ * @param {string} description.character_id
125
+ * @param {string} description.visual_description
126
+ * @param {string} [description.pose]
127
+ * @param {string} [description.emotion]
128
+ * @returns {Promise<Asset>}
129
+ */
130
+ async generateAsset(description) {
131
+ if (!this.initialized) await this.initialize();
132
+
133
+ console.log('🎨 Generating new asset:', description.visual_description);
134
+
135
+ const maxAttempts = 3;
136
+ let attempt = 0;
137
+ let lastError = null;
138
+
139
+ while (attempt < maxAttempts) {
140
+ attempt++;
141
+
142
+ try {
143
+ console.log(` Attempt ${attempt}/${maxAttempts}...`);
144
+
145
+ // Build generation prompt with style parameters
146
+ const prompt = this._buildGenerationPrompt(description);
147
+
148
+ // Generate via Pollinations
149
+ const imageUrl = await this._callPollinationsAPI(prompt, description);
150
+
151
+ // Download and save asset
152
+ const filePath = await this._downloadAsset(imageUrl, description);
153
+
154
+ // Create asset metadata
155
+ const asset = {
156
+ asset_id: this._generateAssetId(description),
157
+ asset_type: description.asset_type,
158
+ character_id: description.character_id || null,
159
+ pose: description.pose || null,
160
+ emotion: description.emotion || null,
161
+ visual_description: description.visual_description,
162
+ file_path: filePath,
163
+ style_parameters: this.styleParams,
164
+ metadata: {
165
+ creation_date: new Date().toISOString(),
166
+ usage_count: 0,
167
+ variants: []
168
+ },
169
+ schema_version: '1.0.0'
170
+ };
171
+
172
+ // Validate style consistency
173
+ const validation = await this.validateAsset(asset);
174
+ if (!validation.valid) {
175
+ throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
176
+ }
177
+
178
+ // Save to registry
179
+ await this._saveAsset(asset);
180
+
181
+ console.log(`✅ Asset generated: ${asset.asset_id}`);
182
+ return asset;
183
+
184
+ } catch (error) {
185
+ lastError = error;
186
+ console.error(` ❌ Attempt ${attempt} failed:`, error.message);
187
+
188
+ if (attempt < maxAttempts) {
189
+ await this._sleep(2000); // Wait 2s before retry
190
+ }
191
+ }
192
+ }
193
+
194
+ throw new Error(`Asset generation failed after ${maxAttempts} attempts. Last error: ${lastError.message}`);
195
+ }
196
+
197
+ /**
198
+ * Compose character from identity + pose + emotion layers
199
+ *
200
+ * @param {string} characterId - Character identifier
201
+ * @param {string} pose - Pose variant
202
+ * @param {string} emotion - Emotion overlay
203
+ * @returns {Promise<string>} - Path to composed image
204
+ */
205
+ async composeCharacter(characterId, pose, emotion) {
206
+ if (!this.initialized) await this.initialize();
207
+
208
+ console.log(`🎭 Composing character: ${characterId} / ${pose} / ${emotion}`);
209
+
210
+ const identity = await this.findAsset({ asset_type: 'character_identity', character_id: characterId });
211
+ const poseAsset = await this.findAsset({ asset_type: 'pose', character_id: characterId, pose });
212
+ const emotionAsset = await this.findAsset({ asset_type: 'emotion', character_id: characterId, emotion });
213
+
214
+ if (!identity || !poseAsset) {
215
+ throw new Error(`Missing base components for ${characterId}/${pose}/${emotion}`);
216
+ }
217
+
218
+ const outputPath = path.join(this.assetsDir, 'composed', `${characterId}_${pose}_${emotion}.png`);
219
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
220
+
221
+ // If output already exists (cache hit), return immediately
222
+ try {
223
+ await fs.access(outputPath);
224
+ console.log(` ✅ Using cached composition: ${outputPath}`);
225
+ return outputPath;
226
+ } catch { /* not cached — compose */ }
227
+
228
+ // Try FFmpeg overlay composition (identity base + pose overlay + optional emotion overlay)
229
+ const { spawnSync } = require('child_process');
230
+ const layers = [identity.file_path, poseAsset.file_path];
231
+ if (emotionAsset) layers.push(emotionAsset.file_path);
232
+
233
+ // Build FFmpeg filter: stack overlays with overlay=0:0, alpha blending
234
+ const inputs = layers.flatMap(p => ['-i', p]);
235
+ let filterComplex, mapArg;
236
+ if (layers.length === 3) {
237
+ filterComplex = '[0:v][1:v]overlay=0:0[tmp];[tmp][2:v]overlay=0:0[out]';
238
+ mapArg = '[out]';
239
+ } else {
240
+ filterComplex = '[0:v][1:v]overlay=0:0[out]';
241
+ mapArg = '[out]';
242
+ }
243
+
244
+ const ffmpegResult = spawnSync('ffmpeg', [
245
+ '-nostdin', '-y',
246
+ ...inputs,
247
+ '-filter_complex', filterComplex,
248
+ '-map', mapArg,
249
+ '-frames:v', '1',
250
+ outputPath,
251
+ ], { timeout: 30000, encoding: 'utf8' });
252
+
253
+ if (ffmpegResult.status === 0 && fsSync.existsSync(outputPath)) {
254
+ console.log(` ✅ FFmpeg composition complete: ${outputPath}`);
255
+ return outputPath;
256
+ }
257
+
258
+ // FFmpeg unavailable or failed — fall back to pose-only asset
259
+ console.warn(` ⚠️ FFmpeg composition failed (${ffmpegResult.stderr?.slice(0, 120)}), using pose asset`);
260
+ return poseAsset.file_path;
261
+ }
262
+
263
+ /**
264
+ * Validate asset style consistency
265
+ *
266
+ * @param {Asset} asset - Asset to validate
267
+ * @returns {Promise<ValidationResult>}
268
+ */
269
+ async validateAsset(asset) {
270
+ const errors = [];
271
+
272
+ // Check file exists
273
+ try {
274
+ await fs.access(asset.file_path);
275
+ } catch {
276
+ errors.push('File does not exist');
277
+ }
278
+
279
+ // Check style parameters match
280
+ if (!asset.style_parameters) {
281
+ errors.push('Missing style parameters');
282
+ } else {
283
+ if (asset.style_parameters.ink_style !== this.styleParams.ink_style) {
284
+ errors.push(`Ink style mismatch: expected ${this.styleParams.ink_style}`);
285
+ }
286
+
287
+ if (asset.style_parameters.line_weight < 2 || asset.style_parameters.line_weight > 4) {
288
+ errors.push('Line weight out of range (2-4)');
289
+ }
290
+
291
+ if (asset.style_parameters.resolution !== this.styleParams.resolution) {
292
+ errors.push(`Resolution mismatch: expected ${this.styleParams.resolution}`);
293
+ }
294
+ }
295
+
296
+ // Check schema version
297
+ if (!asset.schema_version) {
298
+ errors.push('Missing schema version');
299
+ }
300
+
301
+ return {
302
+ valid: errors.length === 0,
303
+ errors: errors
304
+ };
305
+ }
306
+
307
+ /**
308
+ * List assets by filter
309
+ *
310
+ * @param {Object} filter - Filter criteria
311
+ * @returns {Promise<Asset[]>}
312
+ */
313
+ async listAssets(filter = {}) {
314
+ if (!this.initialized) await this.initialize();
315
+
316
+ let results = Array.from(this.assets.values());
317
+
318
+ if (filter.asset_type) {
319
+ results = results.filter(a => a.asset_type === filter.asset_type);
320
+ }
321
+
322
+ if (filter.character_id) {
323
+ results = results.filter(a => a.character_id === filter.character_id);
324
+ }
325
+
326
+ return results;
327
+ }
328
+
329
+ // ============================================
330
+ // PRIVATE METHODS
331
+ // ============================================
332
+
333
+ async _ensureDirectories() {
334
+ const dirs = [
335
+ path.join(this.assetsDir, 'characters'),
336
+ path.join(this.assetsDir, 'props'),
337
+ path.join(this.assetsDir, 'backgrounds'),
338
+ path.join(this.assetsDir, 'effects'),
339
+ path.join(this.assetsDir, 'metadata'),
340
+ path.join(this.assetsDir, 'composed')
341
+ ];
342
+
343
+ for (const dir of dirs) {
344
+ await fs.mkdir(dir, { recursive: true });
345
+ }
346
+ }
347
+
348
+ async _loadAssetRegistry() {
349
+ try {
350
+ const content = await fs.readFile(this.metadataFile, 'utf8');
351
+ const registry = JSON.parse(content);
352
+
353
+ for (const asset of registry.assets || []) {
354
+ this.assets.set(asset.asset_id, asset);
355
+ }
356
+
357
+ console.log(` Loaded ${this.assets.size} assets from registry`);
358
+ } catch (error) {
359
+ if (error.code === 'ENOENT') {
360
+ console.log(' No existing registry, starting fresh');
361
+ await this._saveRegistry();
362
+ } else {
363
+ throw error;
364
+ }
365
+ }
366
+ }
367
+
368
+ async _loadEmbeddings() {
369
+ try {
370
+ const content = await fs.readFile(this.embeddingsFile, 'utf8');
371
+ const embeddings = JSON.parse(content);
372
+
373
+ for (const [assetId, embedding] of Object.entries(embeddings)) {
374
+ this.embeddings.set(assetId, embedding);
375
+ }
376
+
377
+ console.log(` Loaded ${this.embeddings.size} embeddings`);
378
+ } catch (error) {
379
+ if (error.code === 'ENOENT') {
380
+ console.log(' No embeddings cache, will build on demand');
381
+ } else {
382
+ throw error;
383
+ }
384
+ }
385
+ }
386
+
387
+ _filterByMetadata(query) {
388
+ const results = [];
389
+
390
+ for (const asset of this.assets.values()) {
391
+ let matches = true;
392
+
393
+ if (query.asset_type && asset.asset_type !== query.asset_type) {
394
+ matches = false;
395
+ }
396
+
397
+ if (query.character_id && asset.character_id !== query.character_id) {
398
+ matches = false;
399
+ }
400
+
401
+ if (query.pose && asset.pose !== query.pose && asset.pose !== null) {
402
+ matches = false;
403
+ }
404
+
405
+ if (query.emotion && asset.emotion !== query.emotion && asset.emotion !== null) {
406
+ matches = false;
407
+ }
408
+
409
+ if (matches) {
410
+ results.push(asset);
411
+ }
412
+ }
413
+
414
+ return results;
415
+ }
416
+
417
+ async _semanticSearch(description) {
418
+ // Keyword-based semantic search against visual_description fields in the registry.
419
+ // No embedding API required — scores by term overlap.
420
+ if (this.assets.size === 0) return null;
421
+
422
+ const queryText = (
423
+ description.visual_description || description.character_id || ''
424
+ ).toLowerCase();
425
+
426
+ if (!queryText) return null;
427
+
428
+ const queryTerms = queryText
429
+ .replace(/[^a-z0-9\s]/g, ' ')
430
+ .split(/\s+/)
431
+ .filter(t => t.length > 2);
432
+
433
+ if (queryTerms.length === 0) return null;
434
+
435
+ let bestAsset = null;
436
+ let bestScore = 0;
437
+
438
+ for (const asset of this.assets.values()) {
439
+ // Skip type mismatch if asset_type is specified
440
+ if (description.asset_type && asset.asset_type !== description.asset_type) continue;
441
+
442
+ const corpus = [
443
+ asset.visual_description || '',
444
+ asset.asset_type || '',
445
+ asset.character_id || '',
446
+ asset.pose || '',
447
+ asset.emotion || '',
448
+ ...(asset.tags || []),
449
+ ].join(' ').toLowerCase();
450
+
451
+ const matchCount = queryTerms.filter(t => corpus.includes(t)).length;
452
+ const score = matchCount / queryTerms.length;
453
+
454
+ if (score > bestScore) {
455
+ bestScore = score;
456
+ bestAsset = asset;
457
+ }
458
+ }
459
+
460
+ // Require at least 30% term overlap to be a meaningful match
461
+ if (bestScore >= 0.3) {
462
+ console.log(` 🔍 Semantic search match (score ${(bestScore * 100).toFixed(0)}%): ${bestAsset.asset_id}`);
463
+ return bestAsset;
464
+ }
465
+
466
+ return null;
467
+ }
468
+
469
+ _buildGenerationPrompt(description) {
470
+ const basePrompt = [
471
+ description.visual_description,
472
+ 'Simple History style',
473
+ 'Ink Explainer style',
474
+ 'Educational whiteboard animation',
475
+ 'Thick black ink marker outlines',
476
+ 'Flat colors',
477
+ 'Pure white background',
478
+ 'Comic book illustration',
479
+ 'Clear silhouette',
480
+ 'Expressive cartoon face',
481
+ 'No photorealism',
482
+ 'No text',
483
+ 'No watermark'
484
+ ];
485
+
486
+ return basePrompt.join(', ');
487
+ }
488
+
489
+ async _callPollinationsAPI(prompt, description) {
490
+ const model = process.env.POLLINATIONS_MODEL || 'flux';
491
+ const seed = this._generateSeed(description);
492
+
493
+ const imageUrl = new URL('https://image.pollinations.ai/prompt/' + encodeURIComponent(prompt));
494
+ imageUrl.searchParams.set('width', '1920');
495
+ imageUrl.searchParams.set('height', '1080');
496
+ imageUrl.searchParams.set('model', model);
497
+ imageUrl.searchParams.set('nologo', 'true');
498
+ imageUrl.searchParams.set('seed', seed.toString());
499
+
500
+ console.log(` Calling Pollinations API (${model}, seed: ${seed})...`);
501
+
502
+ // Return just the URL string — generateAsset → _downloadAsset does the actual download.
503
+ // Previously this called _downloadAsset itself which caused a double-download when
504
+ // generateAsset also called _downloadAsset with the returned path.
505
+ return imageUrl.toString();
506
+ }
507
+
508
+ async _downloadAsset(imageUrl, description) {
509
+ // ── Determine output path ──────────────────────────────────────────────────
510
+ let relativePath;
511
+ if (description.asset_type === 'character_identity') {
512
+ relativePath = `characters/${description.character_id}/identity_v1.png`;
513
+ } else if (description.asset_type === 'pose') {
514
+ relativePath = `characters/${description.character_id}/poses/${description.pose}.png`;
515
+ } else if (description.asset_type === 'emotion') {
516
+ relativePath = `characters/${description.character_id}/emotions/${description.emotion}.png`;
517
+ } else if (description.asset_type === 'prop') {
518
+ relativePath = `props/${description.character_id || 'generic'}.png`;
519
+ } else if (description.asset_type === 'background') {
520
+ relativePath = `backgrounds/${description.character_id || 'generic'}.png`;
521
+ } else if (description.asset_type === 'effect') {
522
+ relativePath = `effects/${description.character_id || 'generic'}.svg`;
523
+ } else {
524
+ relativePath = `generated/${Date.now()}.png`;
525
+ }
526
+
527
+ const fullPath = path.join(this.assetsDir, relativePath);
528
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
529
+
530
+ // ── HTTP download with redirect following (max 5 hops) ───────────────────
531
+ await this._httpDownload(imageUrl, fullPath, 5);
532
+
533
+ return fullPath;
534
+ }
535
+
536
+ /**
537
+ * Download a URL to disk, following up to maxRedirects redirects.
538
+ * Works with both http:// and https:// URLs.
539
+ * @private
540
+ */
541
+ _httpDownload(url, destPath, maxRedirects) {
542
+ return new Promise((resolve, reject) => {
543
+ const parsedUrl = new URL(url);
544
+ const client = parsedUrl.protocol === 'https:' ? https : http;
545
+
546
+ const req = client.get(url, {
547
+ timeout: 120_000,
548
+ headers: { 'User-Agent': 'FrameForge/1.1 (+https://github.com/frameforge)' },
549
+ }, (res) => {
550
+ // Follow redirects
551
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308)
552
+ && res.headers.location && maxRedirects > 0) {
553
+ res.resume(); // drain response
554
+ return this._httpDownload(res.headers.location, destPath, maxRedirects - 1)
555
+ .then(resolve).catch(reject);
556
+ }
557
+
558
+ if (res.statusCode < 200 || res.statusCode >= 300) {
559
+ res.resume();
560
+ return reject(new Error(`HTTP ${res.statusCode} downloading asset from ${url}`));
561
+ }
562
+
563
+ const writeStream = fsSync.createWriteStream(destPath);
564
+ res.pipe(writeStream);
565
+
566
+ writeStream.on('finish', () => {
567
+ writeStream.close();
568
+ resolve(destPath);
569
+ });
570
+ writeStream.on('error', (err) => {
571
+ fsSync.unlink(destPath, () => {}); // clean up partial file
572
+ reject(err);
573
+ });
574
+ res.on('error', (err) => {
575
+ writeStream.destroy();
576
+ fsSync.unlink(destPath, () => {});
577
+ reject(err);
578
+ });
579
+ });
580
+
581
+ req.on('error', reject);
582
+ req.on('timeout', () => {
583
+ req.destroy();
584
+ reject(new Error(`Timeout downloading asset from ${url}`));
585
+ });
586
+ });
587
+ }
588
+
589
+ async _saveAsset(asset) {
590
+ this.assets.set(asset.asset_id, asset);
591
+ await this._saveRegistry();
592
+ }
593
+
594
+ async _saveRegistry() {
595
+ const registry = {
596
+ schema_version: '1.0.0',
597
+ last_updated: new Date().toISOString(),
598
+ assets: Array.from(this.assets.values())
599
+ };
600
+
601
+ // Atomic write: write to a temp file then rename so concurrent reads never
602
+ // see a partial JSON file, and concurrent saves don't corrupt each other.
603
+ const tmpPath = this.metadataFile + '.tmp';
604
+ await fs.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf8');
605
+ await fs.rename(tmpPath, this.metadataFile);
606
+ }
607
+
608
+ _generateAssetId(description) {
609
+ // Deterministic ID based on content, not time — ensures idempotency so
610
+ // re-generating the same asset description returns the same ID and doesn't
611
+ // create duplicate registry entries.
612
+ const parts = [
613
+ description.character_id || 'generic',
614
+ description.asset_type,
615
+ description.pose || '',
616
+ description.emotion || '',
617
+ ];
618
+ const base = parts.filter(Boolean).join('_');
619
+ // Short hash of the visual_description for uniqueness within the same type/character
620
+ const hash = require('crypto')
621
+ .createHash('sha256')
622
+ .update(description.visual_description || base)
623
+ .digest('hex')
624
+ .slice(0, 8);
625
+ return `${base}_${hash}`;
626
+ }
627
+
628
+ _generateSeed(description) {
629
+ const str = JSON.stringify(description);
630
+ const hash = crypto.createHash('md5').update(str).digest('hex');
631
+ return parseInt(hash.substring(0, 8), 16) % 100000;
632
+ }
633
+
634
+ _sleep(ms) {
635
+ return new Promise(resolve => setTimeout(resolve, ms));
636
+ }
637
+ }
638
+
639
+ module.exports = { AssetLibrary };