@amg555/frameforge-mcp 0.0.0-stage → 2.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.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +431 -0
- package/CLAUDE.md +278 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +359 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +144 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +378 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- 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 };
|