@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,134 @@
1
+ /**
2
+ * Retry / Timeout / Error Policy
3
+ *
4
+ * Classifies errors as RETRYABLE, NON_RETRYABLE, or FATAL.
5
+ * Applies exponential backoff with jitter for RETRYABLE errors.
6
+ * Dead-letters jobs that exceed max attempts.
7
+ * Stage timeouts enforced via Promise.race.
8
+ *
9
+ * Usage:
10
+ * const policy = new RetryPolicy();
11
+ * const result = await policy.executeStage('Research', async () => {
12
+ * return await doResearch();
13
+ * });
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // Stage timeouts in milliseconds
19
+ const STAGE_TIMEOUTS_MS = {
20
+ Research: 5 * 60 * 1000,
21
+ // Script with 32k max-token LLM calls can exceed 3 min on slower providers —
22
+ // raised to 5 min to match Research.
23
+ Script: 5 * 60 * 1000,
24
+ Asset_Generation: 10 * 60 * 1000,
25
+ Video_Rendering: 30 * 60 * 1000,
26
+ Shorts_Extraction:10 * 60 * 1000,
27
+ Quality_Check: 2 * 60 * 1000,
28
+ Packaging: 1 * 60 * 1000,
29
+ Publishing: 5 * 60 * 1000,
30
+ // per-request timeouts
31
+ HTTP_REQUEST: 30 * 1000,
32
+ TTS: 2 * 60 * 1000,
33
+ IMAGE_GENERATION: 3 * 60 * 1000,
34
+ FFMPEG: 30 * 60 * 1000,
35
+ };
36
+
37
+ // Error classification patterns
38
+ const RETRYABLE_PATTERNS = [/ECONNREFUSED/, /ETIMEDOUT/, /ECONNRESET/, /socket hang up/, /429/, /503/, /worker unavailable/, /rate limit/i, /timeout/i];
39
+ const NON_RETRYABLE_PATTERNS = [/JSON parse/i, /schema validation/i, /quality gate/i, /parse failed/i, /Too few scenes/i, /Too few claims/i];
40
+ const FATAL_PATTERNS = [/401/, /403/, /Invalid credential/i, /Missing credential/i];
41
+
42
+ class RetryPolicy {
43
+ constructor({ maxAttempts = 3, baseDelayMs = 10000 } = {}) {
44
+ this.maxAttempts = maxAttempts;
45
+ this.baseDelayMs = baseDelayMs;
46
+ }
47
+
48
+ classifyError(error) {
49
+ const msg = String(error?.message || error);
50
+ const code = String(error?.code || '');
51
+ const combined = msg + ' ' + code;
52
+ if (FATAL_PATTERNS.some(p => p.test(combined))) return 'FATAL';
53
+ if (NON_RETRYABLE_PATTERNS.some(p => p.test(combined))) return 'NON_RETRYABLE';
54
+ if (RETRYABLE_PATTERNS.some(p => p.test(combined))) return 'RETRYABLE';
55
+ const status = error?.status || error?.statusCode;
56
+ if (status === 401 || status === 403) return 'FATAL';
57
+ if (status === 429 || status >= 500) return 'RETRYABLE';
58
+ if (status >= 400) return 'NON_RETRYABLE';
59
+ return 'NON_RETRYABLE'; // conservative default
60
+ }
61
+
62
+ /**
63
+ * Execute a stage function with timeout and retry.
64
+ * @param {string} stageName
65
+ * @param {() => Promise<any>} fn
66
+ * @returns {Promise<any>}
67
+ */
68
+ async executeStage(stageName, fn) {
69
+ const timeoutMs = STAGE_TIMEOUTS_MS[stageName] || (30 * 60 * 1000);
70
+ let lastError;
71
+
72
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
73
+ try {
74
+ return await this._withTimeout(fn(), timeoutMs, stageName);
75
+ } catch (err) {
76
+ lastError = err;
77
+ const errorClass = this.classifyError(err);
78
+
79
+ if (errorClass === 'FATAL') {
80
+ throw Object.assign(err, { errorClass: 'FATAL', stageName, attempt });
81
+ }
82
+
83
+ if (errorClass === 'NON_RETRYABLE') {
84
+ throw Object.assign(err, { errorClass: 'NON_RETRYABLE', stageName, attempt });
85
+ }
86
+
87
+ // RETRYABLE
88
+ if (attempt < this.maxAttempts) {
89
+ const delay = this._backoffDelay(attempt);
90
+ console.warn(`[RetryPolicy] ${stageName} attempt ${attempt}/${this.maxAttempts} failed (RETRYABLE): ${err.message} — retry in ${delay}ms`);
91
+ await this._sleep(delay);
92
+ }
93
+ }
94
+ }
95
+
96
+ const deadLetterErr = Object.assign(lastError, {
97
+ errorClass: 'DEAD_LETTER',
98
+ stageName,
99
+ attempt: this.maxAttempts,
100
+ });
101
+ console.error(`[RetryPolicy] ${stageName} dead-lettered after ${this.maxAttempts} attempts: ${lastError.message}`);
102
+ throw deadLetterErr;
103
+ }
104
+
105
+ /**
106
+ * Execute a single operation (not a stage) with timeout only.
107
+ * No retry — callers handle that via executeStage.
108
+ */
109
+ async withTimeout(fn, timeoutMs, label = 'operation') {
110
+ return this._withTimeout(fn, timeoutMs, label);
111
+ }
112
+
113
+ _withTimeout(promise, ms, label) {
114
+ return new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ reject(Object.assign(new Error(`${label} timed out after ${ms}ms`), { code: 'ETIMEDOUT' }));
117
+ }, ms);
118
+ Promise.resolve(promise).then(
119
+ v => { clearTimeout(timer); resolve(v); },
120
+ e => { clearTimeout(timer); reject(e); }
121
+ );
122
+ });
123
+ }
124
+
125
+ _backoffDelay(attempt) {
126
+ // Exponential with full jitter: random(0, baseDelay * 2^attempt)
127
+ const cap = this.baseDelayMs * Math.pow(2, attempt);
128
+ return Math.floor(Math.random() * cap);
129
+ }
130
+
131
+ _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
132
+ }
133
+
134
+ module.exports = { RetryPolicy, STAGE_TIMEOUTS_MS };
@@ -0,0 +1,297 @@
1
+ /**
2
+ * PostgreSQL-backed production state machine.
3
+ *
4
+ * Productions: QUEUED → RUNNING → SUCCEEDED / FAILED / WAITING_REVIEW / CANCELLED
5
+ * Stages: PENDING → RUNNING → SUCCEEDED / FAILED / SKIPPED / WAITING_REVIEW
6
+ *
7
+ * Never infer state from n8n execution history — always read from this table.
8
+ *
9
+ * Usage (filesystem mode — no PG required for single-worker):
10
+ * const sm = new StateMachine({ mode: 'file', stateDir: '/home/node/.n8n-files/state' });
11
+ * await sm.init();
12
+ * const id = await sm.createProduction({ topic, pipelineVersion: '1.0.0' });
13
+ * await sm.startStage(id, 'Research');
14
+ * await sm.completeStage(id, 'Research', { outputHash: '...' });
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs').promises;
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+
23
+ const PRODUCTION_STATUSES = ['QUEUED','RUNNING','SUCCEEDED','FAILED','WAITING_REVIEW','CANCELLED'];
24
+ const STAGE_STATUSES = ['PENDING','RUNNING','SUCCEEDED','FAILED','SKIPPED','WAITING_REVIEW'];
25
+
26
+ const ALLOWED_TRANSITIONS = {
27
+ QUEUED: ['RUNNING','CANCELLED'],
28
+ RUNNING: ['SUCCEEDED','FAILED','WAITING_REVIEW','CANCELLED'],
29
+ WAITING_REVIEW: ['RUNNING','SUCCEEDED','FAILED','CANCELLED'],
30
+ SUCCEEDED: [],
31
+ FAILED: ['QUEUED'], // allow retry
32
+ CANCELLED: [],
33
+ };
34
+
35
+ class StateMachine {
36
+ /**
37
+ * @param {{ mode: 'file'|'pg', stateDir?: string, pg?: object }} config
38
+ */
39
+ constructor(config = {}) {
40
+ this.mode = config.mode || 'file';
41
+ const _os = require('os'); const _path = require('path');
42
+ this.stateDir = config.stateDir || _path.join(process.env.N8N_FILES_DIR || _path.join(_os.homedir(), '.frameforge-files'), 'state');
43
+ this.pg = config.pg || null; // pg.Pool instance when mode === 'pg'
44
+ // Per-productionId write mutex — prevents concurrent file writes from
45
+ // parallel asset generation (assets.js runs up to 4 scenes at once).
46
+ // Each entry is a Promise chain; new writes append to the tail.
47
+ this._writeLocks = new Map();
48
+ }
49
+
50
+ async init() {
51
+ if (this.mode === 'file') {
52
+ await fs.mkdir(this.stateDir, { recursive: true });
53
+ } else {
54
+ await this._pgInit();
55
+ }
56
+ }
57
+
58
+ // ── Production lifecycle ──────────────────────────────────────────────────
59
+
60
+ async createProduction({ topic, pipelineVersion = '1.0.0', schemaVersion = '1.0.0', metadata = {} }) {
61
+ const productionId = `vid_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
62
+ const record = {
63
+ productionId,
64
+ status: 'QUEUED',
65
+ pipelineVersion,
66
+ schemaVersion,
67
+ topic,
68
+ createdAt: new Date().toISOString(),
69
+ startedAt: null,
70
+ completedAt: null,
71
+ configHash: this._hash(JSON.stringify({ topic, pipelineVersion })),
72
+ stages: {},
73
+ metadata,
74
+ };
75
+ await this._saveProduction(productionId, record);
76
+ return productionId;
77
+ }
78
+
79
+ async transitionProduction(productionId, newStatus) {
80
+ const record = await this._loadProduction(productionId);
81
+ if (!PRODUCTION_STATUSES.includes(newStatus)) throw new Error(`Invalid status: ${newStatus}`);
82
+ const allowed = ALLOWED_TRANSITIONS[record.status] || [];
83
+ if (!allowed.includes(newStatus)) {
84
+ throw new Error(`Illegal transition ${record.status} → ${newStatus} for ${productionId}`);
85
+ }
86
+ record.status = newStatus;
87
+ if (newStatus === 'RUNNING') record.startedAt = new Date().toISOString();
88
+ if (['SUCCEEDED','FAILED','CANCELLED'].includes(newStatus)) record.completedAt = new Date().toISOString();
89
+ await this._saveProduction(productionId, record);
90
+ }
91
+
92
+ async getProduction(productionId) {
93
+ return this._loadProduction(productionId);
94
+ }
95
+
96
+ async listProductions(statusFilter = null) {
97
+ if (this.mode === 'file') {
98
+ const files = await fs.readdir(this.stateDir).catch(() => []);
99
+ const prods = await Promise.all(
100
+ files.filter(f => f.endsWith('.json')).map(f => this._loadProduction(f.replace('.json', '')))
101
+ );
102
+ return statusFilter ? prods.filter(p => p.status === statusFilter) : prods;
103
+ }
104
+ return this._pgListProductions(statusFilter);
105
+ }
106
+
107
+ // ── Stage lifecycle ───────────────────────────────────────────────────────
108
+
109
+ async startStage(productionId, stageName) {
110
+ let record;
111
+ try {
112
+ record = await this._loadProduction(productionId);
113
+ } catch (err) {
114
+ // Production not yet registered (agent-first path skips createProduction).
115
+ // Auto-create a minimal QUEUED record so the stage can proceed.
116
+ const now = new Date().toISOString();
117
+ record = {
118
+ productionId,
119
+ status: 'QUEUED',
120
+ pipelineVersion: '1.0.0',
121
+ schemaVersion: '1.0.0',
122
+ topic: productionId,
123
+ createdAt: now,
124
+ startedAt: null,
125
+ completedAt: null,
126
+ configHash: this._hash(productionId),
127
+ stages: {},
128
+ metadata: {},
129
+ };
130
+ await this._saveProduction(productionId, record);
131
+ }
132
+ record.stages[stageName] = {
133
+ status: 'RUNNING',
134
+ startedAt: new Date().toISOString(),
135
+ completedAt: null,
136
+ inputHash: null,
137
+ outputHash: null,
138
+ retryCount: 0,
139
+ errorMessage: null,
140
+ };
141
+ if (record.status === 'QUEUED') {
142
+ record.status = 'RUNNING';
143
+ record.startedAt = new Date().toISOString();
144
+ }
145
+ await this._saveProduction(productionId, record);
146
+ }
147
+
148
+ async completeStage(productionId, stageName, { outputHash = null, metrics = {} } = {}) {
149
+ const record = await this._loadProduction(productionId);
150
+ const stage = record.stages[stageName] || {};
151
+ stage.status = 'SUCCEEDED';
152
+ stage.completedAt = new Date().toISOString();
153
+ stage.outputHash = outputHash;
154
+ stage.metrics = metrics;
155
+ record.stages[stageName] = stage;
156
+ await this._saveProduction(productionId, record);
157
+ }
158
+
159
+ async failStage(productionId, stageName, errorMessage, { retryable = false } = {}) {
160
+ const record = await this._loadProduction(productionId);
161
+ const stage = record.stages[stageName] || {};
162
+ stage.status = 'FAILED';
163
+ stage.completedAt = new Date().toISOString();
164
+ stage.errorMessage = errorMessage;
165
+ stage.retryCount = (stage.retryCount || 0) + 1;
166
+ stage.retryable = retryable;
167
+ record.stages[stageName] = stage;
168
+ await this._saveProduction(productionId, record);
169
+ }
170
+
171
+ async skipStage(productionId, stageName, reason = '') {
172
+ const record = await this._loadProduction(productionId);
173
+ record.stages[stageName] = { status: 'SKIPPED', reason, skippedAt: new Date().toISOString() };
174
+ await this._saveProduction(productionId, record);
175
+ }
176
+
177
+ async getStage(productionId, stageName) {
178
+ const record = await this._loadProduction(productionId);
179
+ return record.stages[stageName] || { status: 'PENDING' };
180
+ }
181
+
182
+ // ── Idempotency check ─────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Returns true if the stage can be skipped because its output is still valid.
186
+ * Components: inputHash of current inputs + outputHash of cached output + quality flag.
187
+ */
188
+ async canReuseStage(productionId, stageName, currentInputHash) {
189
+ const stage = await this.getStage(productionId, stageName);
190
+ return (
191
+ stage.status === 'SUCCEEDED' &&
192
+ stage.inputHash === currentInputHash &&
193
+ stage.outputHash &&
194
+ stage.qualityPassed !== false
195
+ );
196
+ }
197
+
198
+ async recordInputHash(productionId, stageName, inputHash) {
199
+ const record = await this._loadProduction(productionId);
200
+ record.stages[stageName] = record.stages[stageName] || {};
201
+ record.stages[stageName].inputHash = inputHash;
202
+ await this._saveProduction(productionId, record);
203
+ }
204
+
205
+ // ── Internal helpers ──────────────────────────────────────────────────────
206
+
207
+ _hash(data) {
208
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
209
+ }
210
+
211
+ async _saveProduction(productionId, record) {
212
+ if (this.mode === 'file') {
213
+ // Serialize writes for the same productionId to prevent data loss from
214
+ // concurrent stage updates (e.g. parallel TTS/image generation).
215
+ const prev = this._writeLocks.get(productionId) || Promise.resolve();
216
+ const next = prev.then(async () => {
217
+ const filePath = path.join(this.stateDir, `${productionId}.json`);
218
+ await fs.writeFile(filePath, JSON.stringify(record, null, 2), 'utf8');
219
+ });
220
+ // Store the tail; remove once resolved so the Map doesn't grow unbounded
221
+ this._writeLocks.set(productionId, next.catch(() => {}).then(() => {
222
+ if (this._writeLocks.get(productionId) === next) {
223
+ this._writeLocks.delete(productionId);
224
+ }
225
+ }));
226
+ await next;
227
+ } else {
228
+ await this._pgUpsertProduction(record);
229
+ }
230
+ }
231
+
232
+ async _loadProduction(productionId) {
233
+ if (this.mode === 'file') {
234
+ const filePath = path.join(this.stateDir, `${productionId}.json`);
235
+ try {
236
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
237
+ } catch (err) {
238
+ throw new Error(`Production not found: ${productionId}`);
239
+ }
240
+ }
241
+ return this._pgLoadProduction(productionId);
242
+ }
243
+
244
+ // ── PostgreSQL stubs (wire up a pg.Pool to activate) ─────────────────────
245
+
246
+ async _pgInit() {
247
+ if (!this.pg) throw new Error('pg.Pool required for mode=pg');
248
+ await this.pg.query(`
249
+ CREATE TABLE IF NOT EXISTS productions (
250
+ production_id VARCHAR(64) PRIMARY KEY,
251
+ status VARCHAR(20) NOT NULL DEFAULT 'QUEUED',
252
+ pipeline_version VARCHAR(20),
253
+ schema_version VARCHAR(20),
254
+ topic TEXT,
255
+ created_at TIMESTAMP DEFAULT NOW(),
256
+ started_at TIMESTAMP,
257
+ completed_at TIMESTAMP,
258
+ config_hash VARCHAR(32),
259
+ stages JSONB DEFAULT '{}',
260
+ metadata JSONB DEFAULT '{}'
261
+ );
262
+ `);
263
+ }
264
+
265
+ async _pgUpsertProduction(record) {
266
+ await this.pg.query(`
267
+ INSERT INTO productions (production_id, status, pipeline_version, schema_version, topic,
268
+ created_at, started_at, completed_at, config_hash, stages, metadata)
269
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb)
270
+ ON CONFLICT (production_id) DO UPDATE SET
271
+ status=EXCLUDED.status, started_at=EXCLUDED.started_at,
272
+ completed_at=EXCLUDED.completed_at, stages=EXCLUDED.stages
273
+ `, [record.productionId, record.status, record.pipelineVersion, record.schemaVersion,
274
+ record.topic, record.createdAt, record.startedAt, record.completedAt,
275
+ record.configHash, JSON.stringify(record.stages), JSON.stringify(record.metadata)]);
276
+ }
277
+
278
+ async _pgLoadProduction(productionId) {
279
+ const { rows } = await this.pg.query('SELECT * FROM productions WHERE production_id=$1', [productionId]);
280
+ if (!rows.length) throw new Error(`Production not found: ${productionId}`);
281
+ const r = rows[0];
282
+ return { productionId: r.production_id, status: r.status, pipelineVersion: r.pipeline_version,
283
+ schemaVersion: r.schema_version, topic: r.topic, createdAt: r.created_at,
284
+ startedAt: r.started_at, completedAt: r.completed_at, configHash: r.config_hash,
285
+ stages: r.stages || {}, metadata: r.metadata || {} };
286
+ }
287
+
288
+ async _pgListProductions(statusFilter) {
289
+ const q = statusFilter
290
+ ? 'SELECT * FROM productions WHERE status=$1 ORDER BY created_at DESC'
291
+ : 'SELECT * FROM productions ORDER BY created_at DESC';
292
+ const { rows } = await this.pg.query(q, statusFilter ? [statusFilter] : []);
293
+ return Promise.all(rows.map(r => this._pgLoadProduction(r.production_id)));
294
+ }
295
+ }
296
+
297
+ module.exports = { StateMachine };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Observability + Cost Tracking
3
+ *
4
+ * Writes usage_events to PostgreSQL (when configured) or a local NDJSON log.
5
+ * Every LLM call, image gen, TTS render, and video encode logs a usage event.
6
+ *
7
+ * Usage:
8
+ * const telemetry = new Telemetry({ mode: 'file', logPath: '/home/node/.n8n-files/usage.ndjson' });
9
+ * await telemetry.track({ productionId, stage: 'Script', provider: 'groq', operation: 'script_generation', units: 14500 });
10
+ * const report = await telemetry.costReport(productionId);
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs').promises;
16
+ const path = require('path');
17
+
18
+ // Approximate pricing (USD per unit)
19
+ const COST_TABLE = {
20
+ groq: { tokens: 0.00000059 }, // $0.59/1M tokens (llama-3.3-70b-versatile)
21
+ cerebras: { tokens: 0.00000060 }, // $0.60/1M tokens (llama3.3-70b)
22
+ openrouter: { tokens: 0.00000300 }, // varies by model — rough estimate
23
+ anthropic: { tokens: 0.00000300 }, // $3/1M tokens (claude-sonnet-4-5)
24
+ gemini: { tokens: 0.00000010 }, // $0.10/1M tokens (gemini-2.0-flash)
25
+ mistral: { tokens: 0.00000200 }, // $2/1M tokens (mistral-large-latest)
26
+ together: { tokens: 0.00000090 }, // $0.90/1M tokens (Llama-3.3-70B-Turbo)
27
+ openai: { tokens: 0.00000250 }, // $2.50/1M input tokens (gpt-4o)
28
+ ollama: { tokens: 0 }, // local
29
+ pollinations:{ images: 0 }, // free tier
30
+ falai: { images: 0.003 }, // ~$0.003/image
31
+ replicate: { images: 0.003 },
32
+ dalle: { images: 0.04 }, // DALL-E 3 1024x1024
33
+ elevenlabs: { characters: 0.00003 }, // $30/1M chars
34
+ azure: { characters: 0.000016 },
35
+ 'edge-tts': { characters: 0 }, // free
36
+ openai_tts: { characters: 0.000015 },
37
+ ffmpeg: { seconds: 0 }, // local
38
+ };
39
+
40
+ function estimateCost(provider, operation, units) {
41
+ const prov = COST_TABLE[provider] || {};
42
+ if (operation.includes('token') || operation.includes('script') || operation.includes('research')) return units * (prov.tokens || 0);
43
+ if (operation.includes('image') || operation.includes('doodle') || operation.includes('thumbnail')) return units * (prov.images || 0);
44
+ if (operation.includes('tts') || operation.includes('voice')) return units * (prov.characters || 0);
45
+ if (operation.includes('render') || operation.includes('video') || operation.includes('audio')) return units * (prov.seconds || 0);
46
+ return 0;
47
+ }
48
+
49
+ class Telemetry {
50
+ constructor({ mode = 'file', logPath = require('path').join(process.env.N8N_FILES_DIR || require('path').join(require('os').homedir(), '.frameforge-files'), 'usage.ndjson'), pg = null } = {}) {
51
+ this.mode = mode;
52
+ this.logPath = logPath;
53
+ this.pg = pg;
54
+ }
55
+
56
+ async init() {
57
+ if (this.mode === 'file') {
58
+ await fs.mkdir(path.dirname(this.logPath), { recursive: true });
59
+ } else {
60
+ await this._pgInit();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Track a usage event.
66
+ * @param {{ productionId: string, stage: string, provider: string, operation: string, units: number, metadata?: object }} event
67
+ */
68
+ async track({ productionId, stage, provider, operation, units = 0, metadata = {} }) {
69
+ const event = {
70
+ id: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
71
+ productionId,
72
+ stage,
73
+ provider,
74
+ operation,
75
+ units,
76
+ estimatedCostUsd: estimateCost(provider, operation, units),
77
+ timestamp: new Date().toISOString(),
78
+ schemaVersion: '1.0.0',
79
+ metadata,
80
+ };
81
+
82
+ if (this.mode === 'file') {
83
+ await fs.appendFile(this.logPath, JSON.stringify(event) + '\n');
84
+ } else {
85
+ await this._pgInsert(event);
86
+ }
87
+
88
+ return event;
89
+ }
90
+
91
+ /**
92
+ * Aggregate cost report for a production.
93
+ * @returns {{ totalCostUsd: number, byProvider: object, byStage: object, events: number }}
94
+ */
95
+ async costReport(productionId) {
96
+ const events = await this._loadEvents(productionId);
97
+ const byProvider = {};
98
+ const byStage = {};
99
+ let totalCostUsd = 0;
100
+
101
+ for (const e of events) {
102
+ byProvider[e.provider] = (byProvider[e.provider] || 0) + e.estimatedCostUsd;
103
+ byStage[e.stage] = (byStage[e.stage] || 0) + e.estimatedCostUsd;
104
+ totalCostUsd += e.estimatedCostUsd;
105
+ }
106
+
107
+ return {
108
+ productionId,
109
+ totalCostUsd: Math.round(totalCostUsd * 1e6) / 1e6,
110
+ byProvider: Object.fromEntries(Object.entries(byProvider).map(([k,v]) => [k, Math.round(v * 1e6) / 1e6])),
111
+ byStage: Object.fromEntries(Object.entries(byStage).map(([k,v]) => [k, Math.round(v * 1e6) / 1e6])),
112
+ events: events.length,
113
+ };
114
+ }
115
+
116
+ async _loadEvents(productionId) {
117
+ if (this.mode === 'file') {
118
+ try {
119
+ const raw = await fs.readFile(this.logPath, 'utf8');
120
+ return raw.trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(e => e.productionId === productionId);
121
+ } catch { return []; }
122
+ }
123
+ return this._pgLoadEvents(productionId);
124
+ }
125
+
126
+ async _pgInit() {
127
+ if (!this.pg) throw new Error('pg.Pool required for mode=pg');
128
+ await this.pg.query(`
129
+ CREATE TABLE IF NOT EXISTS usage_events (
130
+ id VARCHAR(32) PRIMARY KEY,
131
+ production_id VARCHAR(64) NOT NULL,
132
+ stage VARCHAR(50),
133
+ provider VARCHAR(50),
134
+ operation VARCHAR(100),
135
+ units DECIMAL,
136
+ estimated_cost_usd DECIMAL(10,6),
137
+ timestamp TIMESTAMP DEFAULT NOW(),
138
+ schema_version VARCHAR(20) DEFAULT '1.0.0',
139
+ metadata JSONB DEFAULT '{}'
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_usage_production ON usage_events(production_id);
142
+ CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage_events(provider);
143
+ CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(timestamp);
144
+ `);
145
+ }
146
+
147
+ async _pgInsert(event) {
148
+ await this.pg.query(`
149
+ INSERT INTO usage_events (id, production_id, stage, provider, operation, units, estimated_cost_usd, timestamp, schema_version, metadata)
150
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
151
+ `, [event.id, event.productionId, event.stage, event.provider, event.operation,
152
+ event.units, event.estimatedCostUsd, event.timestamp, event.schemaVersion,
153
+ JSON.stringify(event.metadata)]);
154
+ }
155
+
156
+ async _pgLoadEvents(productionId) {
157
+ const { rows } = await this.pg.query('SELECT * FROM usage_events WHERE production_id=$1 ORDER BY timestamp', [productionId]);
158
+ return rows.map(r => ({ ...r, productionId: r.production_id, estimatedCostUsd: parseFloat(r.estimated_cost_usd) }));
159
+ }
160
+ }
161
+
162
+ module.exports = { Telemetry, estimateCost };