@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.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +45 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +447 -0
- package/CLAUDE.md +320 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +377 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +200 -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 +392 -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,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FrameForge MCP Server — Local Stdio Transport
|
|
4
|
+
*
|
|
5
|
+
* Implements standard stdio JSON-RPC transport for MCP 2024-11-05.
|
|
6
|
+
* Used for zero-config integration with Claude Code, Cursor, Windsurf, Codex, opencode.
|
|
7
|
+
*
|
|
8
|
+
* When run via `npx frameforge-mcp`, keys are loaded from ~/.frameforge/config.json
|
|
9
|
+
* (written once by `npx frameforge-setup`). Falls back to .env in cwd.
|
|
10
|
+
*
|
|
11
|
+
* IDE config (npx — no clone required):
|
|
12
|
+
* {
|
|
13
|
+
* "mcpServers": {
|
|
14
|
+
* "frameforge": {
|
|
15
|
+
* "command": "npx",
|
|
16
|
+
* "args": ["-y", "frameforge-mcp"]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* IDE config (local clone):
|
|
22
|
+
* {
|
|
23
|
+
* "mcpServers": {
|
|
24
|
+
* "frameforge": {
|
|
25
|
+
* "command": "node",
|
|
26
|
+
* "args": ["/absolute/path/to/frameforge/src/mcp-server/stdio.js"]
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
// Load config BEFORE requiring server.js so env vars are set when providers initialise
|
|
35
|
+
require('../config/load-config').loadConfig();
|
|
36
|
+
|
|
37
|
+
const readline = require('readline');
|
|
38
|
+
const { handleMCPRequest } = require('./server');
|
|
39
|
+
|
|
40
|
+
const rl = readline.createInterface({
|
|
41
|
+
input: process.stdin,
|
|
42
|
+
output: process.stdout,
|
|
43
|
+
terminal: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
process.stderr.write('[FrameForge Stdio MCP] Server initialized. Listening on stdin...\n');
|
|
47
|
+
|
|
48
|
+
rl.on('line', async line => {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed) return;
|
|
51
|
+
|
|
52
|
+
let request;
|
|
53
|
+
try {
|
|
54
|
+
request = JSON.parse(trimmed);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const errorResponse = {
|
|
57
|
+
jsonrpc: '2.0',
|
|
58
|
+
id: null,
|
|
59
|
+
error: { code: -32700, message: `Parse error: ${err.message}` },
|
|
60
|
+
};
|
|
61
|
+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (Array.isArray(request)) {
|
|
67
|
+
const responses = await Promise.all(request.map(r => handleMCPRequest(r)));
|
|
68
|
+
const filtered = responses.filter(r => r !== null);
|
|
69
|
+
if (filtered.length > 0) {
|
|
70
|
+
process.stdout.write(JSON.stringify(filtered) + '\n');
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const response = await handleMCPRequest(request);
|
|
74
|
+
if (response !== null) {
|
|
75
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
process.stderr.write(`[FrameForge Stdio MCP] Request error: ${err.message}\n`);
|
|
80
|
+
const errorResponse = {
|
|
81
|
+
jsonrpc: '2.0',
|
|
82
|
+
id: request.id || null,
|
|
83
|
+
error: { code: -32603, message: `Internal server error: ${err.message}` },
|
|
84
|
+
};
|
|
85
|
+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Manifest — Task #24
|
|
3
|
+
*
|
|
4
|
+
* Every production run creates a deterministic, content-addressed manifest
|
|
5
|
+
* so the pipeline can:
|
|
6
|
+
* 1. Skip re-rendering unchanged scenes (idempotency)
|
|
7
|
+
* 2. Verify artifact integrity on resume
|
|
8
|
+
* 3. Build Artifact Retention Rules (#27)
|
|
9
|
+
* 4. Feed cost tracking (telemetry.js)
|
|
10
|
+
*
|
|
11
|
+
* Manifest structure (written to artifacts/<production_id>/manifest.json):
|
|
12
|
+
* {
|
|
13
|
+
* "schema_version": "1.0.0",
|
|
14
|
+
* "production_id": "prod_...",
|
|
15
|
+
* "pipeline_version": "1.0.0",
|
|
16
|
+
* "narration_hash": "<sha256 of full script text>",
|
|
17
|
+
* "config_hash": "<sha256 of env+provider config>",
|
|
18
|
+
* "created_at": "<ISO 8601>",
|
|
19
|
+
* "scenes": [
|
|
20
|
+
* {
|
|
21
|
+
* "index": 0,
|
|
22
|
+
* "scene_hash": "<sha256 of scene JSON>",
|
|
23
|
+
* "image_path": "...",
|
|
24
|
+
* "image_hash": "<sha256 of image file>",
|
|
25
|
+
* "voice_path": "...",
|
|
26
|
+
* "voice_hash": "<sha256 of voice file>",
|
|
27
|
+
* "rendered_path": "...",
|
|
28
|
+
* "rendered_hash": "<sha256 of rendered scene>",
|
|
29
|
+
* "status": "pending|complete|failed"
|
|
30
|
+
* }
|
|
31
|
+
* ],
|
|
32
|
+
* "master": {
|
|
33
|
+
* "path": "...",
|
|
34
|
+
* "hash": "...",
|
|
35
|
+
* "duration_sec": 0,
|
|
36
|
+
* "size_bytes": 0,
|
|
37
|
+
* "quality_passed": false
|
|
38
|
+
* },
|
|
39
|
+
* "shorts": [],
|
|
40
|
+
* "thumbnail": { "path": "...", "hash": "..." },
|
|
41
|
+
* "audio_config": {
|
|
42
|
+
* "provider": "edge-tts",
|
|
43
|
+
* "voice": "en-US-GuyNeural",
|
|
44
|
+
* "music_path": null,
|
|
45
|
+
* "lufs_target": -14,
|
|
46
|
+
* "true_peak_max": -1
|
|
47
|
+
* },
|
|
48
|
+
* "telemetry": {
|
|
49
|
+
* "total_cost_usd": 0,
|
|
50
|
+
* "stage_durations_ms": {}
|
|
51
|
+
* }
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* Hashes are computed lazily (file hash only when file exists).
|
|
55
|
+
* The manifest file itself is re-written on every stage completion.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
'use strict';
|
|
59
|
+
|
|
60
|
+
const fs = require('fs');
|
|
61
|
+
const path = require('path');
|
|
62
|
+
const crypto = require('crypto');
|
|
63
|
+
|
|
64
|
+
const MANIFEST_SCHEMA_VERSION = '1.0.0';
|
|
65
|
+
const PIPELINE_VERSION = process.env.PIPELINE_VERSION || '1.0.0';
|
|
66
|
+
|
|
67
|
+
class ArtifactManifest {
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ productionId: string, artifactsDir?: string }} opts
|
|
70
|
+
*/
|
|
71
|
+
constructor({ productionId, artifactsDir = require('path').join(process.env.N8N_FILES_DIR || require('path').join(require('os').homedir(), '.frameforge-files'), 'artifacts') } = {}) {
|
|
72
|
+
if (!productionId) throw new Error('productionId is required');
|
|
73
|
+
this.productionId = productionId;
|
|
74
|
+
this.dir = path.join(artifactsDir, productionId);
|
|
75
|
+
this.manifestPath = path.join(this.dir, 'manifest.json');
|
|
76
|
+
this._data = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a new manifest for a fresh production run.
|
|
83
|
+
* Returns the manifest data object.
|
|
84
|
+
*/
|
|
85
|
+
create({ narrationText, configSnapshot, scenes = [], audioConfig = {} } = {}) {
|
|
86
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
87
|
+
this._data = {
|
|
88
|
+
schema_version: MANIFEST_SCHEMA_VERSION,
|
|
89
|
+
production_id: this.productionId,
|
|
90
|
+
pipeline_version: PIPELINE_VERSION,
|
|
91
|
+
narration_hash: narrationText ? _sha256(narrationText) : null,
|
|
92
|
+
config_hash: configSnapshot ? _sha256(JSON.stringify(configSnapshot)) : null,
|
|
93
|
+
created_at: new Date().toISOString(),
|
|
94
|
+
updated_at: new Date().toISOString(),
|
|
95
|
+
scenes: scenes.map((s, i) => this._sceneEntry(s, i)),
|
|
96
|
+
master: { path: null, hash: null, duration_sec: 0, size_bytes: 0, quality_passed: false },
|
|
97
|
+
shorts: [],
|
|
98
|
+
thumbnail: { path: null, hash: null },
|
|
99
|
+
audio_config: {
|
|
100
|
+
provider: process.env.PROVIDER_TTS || 'edge-tts',
|
|
101
|
+
voice: process.env.TTS_VOICE || 'en-US-GuyNeural',
|
|
102
|
+
music_path: null,
|
|
103
|
+
lufs_target: -14,
|
|
104
|
+
true_peak_max: -1,
|
|
105
|
+
...audioConfig,
|
|
106
|
+
},
|
|
107
|
+
telemetry: {
|
|
108
|
+
total_cost_usd: 0,
|
|
109
|
+
stage_durations_ms: {},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
this._write();
|
|
113
|
+
return this._data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load an existing manifest from disk (for resume / idempotency checks).
|
|
118
|
+
* Returns null if no manifest exists.
|
|
119
|
+
*/
|
|
120
|
+
load() {
|
|
121
|
+
if (!fs.existsSync(this.manifestPath)) return null;
|
|
122
|
+
try {
|
|
123
|
+
this._data = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
124
|
+
return this._data;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new Error(`Corrupt manifest at ${this.manifestPath}: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load or create — the canonical entry point for a production run.
|
|
132
|
+
* If an existing manifest exists AND its narration_hash matches → resume.
|
|
133
|
+
* Otherwise → create new (or overwrite stale).
|
|
134
|
+
*/
|
|
135
|
+
loadOrCreate({ narrationText, configSnapshot, scenes = [], audioConfig = {} } = {}) {
|
|
136
|
+
const existing = this.load();
|
|
137
|
+
if (existing) {
|
|
138
|
+
const incomingHash = narrationText ? _sha256(narrationText) : null;
|
|
139
|
+
if (incomingHash && existing.narration_hash === incomingHash) {
|
|
140
|
+
// Same narration — resume
|
|
141
|
+
return { manifest: this._data, resumed: true };
|
|
142
|
+
}
|
|
143
|
+
// Different narration — overwrite
|
|
144
|
+
}
|
|
145
|
+
return { manifest: this.create({ narrationText, configSnapshot, scenes, audioConfig }), resumed: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Scene artifact updates ─────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Mark a scene's image as generated.
|
|
152
|
+
*/
|
|
153
|
+
setSceneImage(sceneIndex, imagePath) {
|
|
154
|
+
const scene = this._getScene(sceneIndex);
|
|
155
|
+
scene.image_path = imagePath;
|
|
156
|
+
scene.image_hash = fs.existsSync(imagePath) ? _fileHash(imagePath) : null;
|
|
157
|
+
this._touch();
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mark a scene's voice track as generated.
|
|
163
|
+
*/
|
|
164
|
+
setSceneVoice(sceneIndex, voicePath) {
|
|
165
|
+
const scene = this._getScene(sceneIndex);
|
|
166
|
+
scene.voice_path = voicePath;
|
|
167
|
+
scene.voice_hash = fs.existsSync(voicePath) ? _fileHash(voicePath) : null;
|
|
168
|
+
this._touch();
|
|
169
|
+
return this;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Mark a scene's rendered MP4 as complete.
|
|
174
|
+
*/
|
|
175
|
+
setSceneRendered(sceneIndex, renderedPath) {
|
|
176
|
+
const scene = this._getScene(sceneIndex);
|
|
177
|
+
scene.rendered_path = renderedPath;
|
|
178
|
+
scene.rendered_hash = fs.existsSync(renderedPath) ? _fileHash(renderedPath) : null;
|
|
179
|
+
scene.status = 'complete';
|
|
180
|
+
this._touch();
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setSceneFailed(sceneIndex, reason) {
|
|
185
|
+
const scene = this._getScene(sceneIndex);
|
|
186
|
+
scene.status = 'failed';
|
|
187
|
+
scene.fail_reason = reason;
|
|
188
|
+
this._touch();
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Master + shorts ────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
setMaster(masterPath, { durationSec = 0, qualityPassed = false } = {}) {
|
|
195
|
+
const stat = fs.existsSync(masterPath) ? fs.statSync(masterPath) : null;
|
|
196
|
+
this._data.master = {
|
|
197
|
+
path: masterPath,
|
|
198
|
+
hash: stat ? _fileHash(masterPath) : null,
|
|
199
|
+
duration_sec: durationSec,
|
|
200
|
+
size_bytes: stat ? stat.size : 0,
|
|
201
|
+
quality_passed: qualityPassed,
|
|
202
|
+
};
|
|
203
|
+
this._touch();
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
addShort({ path: shortPath, durationSec = 0, qualityPassed = false } = {}) {
|
|
208
|
+
const stat = fs.existsSync(shortPath) ? fs.statSync(shortPath) : null;
|
|
209
|
+
this._data.shorts.push({
|
|
210
|
+
path: shortPath,
|
|
211
|
+
hash: stat ? _fileHash(shortPath) : null,
|
|
212
|
+
duration_sec: durationSec,
|
|
213
|
+
size_bytes: stat ? stat.size : 0,
|
|
214
|
+
quality_passed: qualityPassed,
|
|
215
|
+
});
|
|
216
|
+
this._touch();
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setThumbnail(thumbnailPath) {
|
|
221
|
+
this._data.thumbnail = {
|
|
222
|
+
path: thumbnailPath,
|
|
223
|
+
hash: fs.existsSync(thumbnailPath) ? _fileHash(thumbnailPath) : null,
|
|
224
|
+
};
|
|
225
|
+
this._touch();
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Telemetry ─────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
recordStageDuration(stage, durationMs) {
|
|
232
|
+
this._data.telemetry.stage_durations_ms[stage] = durationMs;
|
|
233
|
+
this._touch();
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
addCost(costUsd) {
|
|
238
|
+
this._data.telemetry.total_cost_usd =
|
|
239
|
+
parseFloat((this._data.telemetry.total_cost_usd + costUsd).toFixed(6));
|
|
240
|
+
this._touch();
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Idempotency checks ────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Returns true if the scene at sceneIndex has a valid rendered MP4 on disk.
|
|
248
|
+
* Used to skip re-rendering on resume.
|
|
249
|
+
*/
|
|
250
|
+
isSceneComplete(sceneIndex) {
|
|
251
|
+
const scene = this._data?.scenes?.[sceneIndex];
|
|
252
|
+
if (!scene || scene.status !== 'complete') return false;
|
|
253
|
+
return !!scene.rendered_path && fs.existsSync(scene.rendered_path)
|
|
254
|
+
&& fs.statSync(scene.rendered_path).size > 50000;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Returns true if master exists, has correct hash, and passed quality gate.
|
|
259
|
+
*/
|
|
260
|
+
isMasterComplete() {
|
|
261
|
+
const m = this._data?.master;
|
|
262
|
+
if (!m?.path || !m.quality_passed) return false;
|
|
263
|
+
if (!fs.existsSync(m.path)) return false;
|
|
264
|
+
return _fileHash(m.path) === m.hash;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Getters ───────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
get data() { return this._data; }
|
|
270
|
+
get scenes() { return this._data?.scenes || []; }
|
|
271
|
+
get master() { return this._data?.master || null; }
|
|
272
|
+
get shorts() { return this._data?.shorts || []; }
|
|
273
|
+
|
|
274
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
_sceneEntry(sceneData, index) {
|
|
277
|
+
return {
|
|
278
|
+
index,
|
|
279
|
+
scene_hash: _sha256(JSON.stringify(sceneData)),
|
|
280
|
+
image_path: null,
|
|
281
|
+
image_hash: null,
|
|
282
|
+
voice_path: null,
|
|
283
|
+
voice_hash: null,
|
|
284
|
+
rendered_path: null,
|
|
285
|
+
rendered_hash: null,
|
|
286
|
+
status: 'pending',
|
|
287
|
+
fail_reason: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_getScene(index) {
|
|
292
|
+
if (!this._data) throw new Error('Manifest not loaded — call create() or load() first');
|
|
293
|
+
const scene = this._data.scenes[index];
|
|
294
|
+
if (!scene) throw new Error(`Scene index ${index} not in manifest (${this._data.scenes.length} scenes)`);
|
|
295
|
+
return scene;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_touch() {
|
|
299
|
+
if (this._data) {
|
|
300
|
+
this._data.updated_at = new Date().toISOString();
|
|
301
|
+
this._write();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_write() {
|
|
306
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
307
|
+
fs.writeFileSync(this.manifestPath, JSON.stringify(this._data, null, 2), 'utf8');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
function _sha256(text) {
|
|
314
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _fileHash(filePath) {
|
|
318
|
+
try {
|
|
319
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
320
|
+
} catch { return null; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { ArtifactManifest };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Retention Rules — Task #27
|
|
3
|
+
*
|
|
4
|
+
* Defines which artifacts to keep permanently, for 30 days, or delete immediately.
|
|
5
|
+
* Runs as part of post-publish cleanup and the nightly cron sweep.
|
|
6
|
+
*
|
|
7
|
+
* Policy:
|
|
8
|
+
* PERMANENT — master MP4, shorts, thumbnails, manifests, character assets
|
|
9
|
+
* 30_DAY — intermediate scene renders (scene_N_out.mp4), concat files, TTS audio
|
|
10
|
+
* DELETE_NOW — temp files from failed runs, raw_concat before mixing
|
|
11
|
+
*
|
|
12
|
+
* Usage (called after successful publish):
|
|
13
|
+
* const { applyRetentionRules } = require('./artifact-retention');
|
|
14
|
+
* applyRetentionRules({ productionId, filesDir, manifestData });
|
|
15
|
+
*
|
|
16
|
+
* Usage (nightly sweep):
|
|
17
|
+
* const { sweepExpiredIntermediates } = require('./artifact-retention');
|
|
18
|
+
* sweepExpiredIntermediates({ filesDir, dryRun: false });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const RETENTION_DAYS = parseInt(process.env.RETENTION_DAYS_INTERMEDIATES || '30', 10);
|
|
27
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
// ── Permanent file patterns ───────────────────────────────────────────────────
|
|
30
|
+
// These are never deleted by the sweep.
|
|
31
|
+
const PERMANENT_PATTERNS = [
|
|
32
|
+
/final_video_.*\.mp4$/, // master video
|
|
33
|
+
/short_.*\.mp4$/, // shorts
|
|
34
|
+
/thumbnail_.*\.(jpg|png|webp)$/,
|
|
35
|
+
/manifest\.json$/, // production manifests
|
|
36
|
+
/assets\//, // character/prop assets
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ── 30-day intermediate patterns ─────────────────────────────────────────────
|
|
40
|
+
// Deleted after RETENTION_DAYS days.
|
|
41
|
+
const INTERMEDIATE_PATTERNS = [
|
|
42
|
+
/scene_\d+_out\.mp4$/,
|
|
43
|
+
/scene_\d+_voice\.(m4a|mp3|aac)$/,
|
|
44
|
+
/scene_\d+_image\.(png|jpg|webp)$/,
|
|
45
|
+
/concat_main\.txt$/,
|
|
46
|
+
/raw_concat_.*\.mp4$/,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── Delete-immediately patterns ───────────────────────────────────────────────
|
|
50
|
+
// Cleaned up right after stage completion.
|
|
51
|
+
const TEMP_PATTERNS = [
|
|
52
|
+
/_raw\.mp3$/,
|
|
53
|
+
/\.tmp$/,
|
|
54
|
+
/\.part$/,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Apply retention rules after a successful production run.
|
|
61
|
+
* Deletes temp files immediately; schedules intermediates for 30-day sweep
|
|
62
|
+
* by writing a .retention file alongside each intermediate.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ productionId: string, filesDir: string, dryRun?: boolean }} opts
|
|
65
|
+
* @returns {{ deleted: string[], tagged: string[] }}
|
|
66
|
+
*/
|
|
67
|
+
function applyRetentionRules({ productionId, filesDir, dryRun = false }) {
|
|
68
|
+
const vdir = path.join(filesDir, 'video_tmp');
|
|
69
|
+
const deleted = [];
|
|
70
|
+
const tagged = [];
|
|
71
|
+
|
|
72
|
+
// 1. Delete temp files immediately
|
|
73
|
+
for (const dir of [filesDir, vdir]) {
|
|
74
|
+
if (!fs.existsSync(dir)) continue;
|
|
75
|
+
for (const file of _listFiles(dir)) {
|
|
76
|
+
if (TEMP_PATTERNS.some(p => p.test(file))) {
|
|
77
|
+
if (!dryRun) _safeUnlink(file);
|
|
78
|
+
deleted.push(file);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Tag intermediates with a .retention file (mtime-based expiry)
|
|
84
|
+
if (fs.existsSync(vdir)) {
|
|
85
|
+
for (const file of _listFiles(vdir)) {
|
|
86
|
+
if (INTERMEDIATE_PATTERNS.some(p => p.test(file))) {
|
|
87
|
+
const tagPath = file + '.retention';
|
|
88
|
+
if (!dryRun && !fs.existsSync(tagPath)) {
|
|
89
|
+
const tag = {
|
|
90
|
+
production_id: productionId,
|
|
91
|
+
created_at: new Date().toISOString(),
|
|
92
|
+
expires_at: new Date(Date.now() + RETENTION_DAYS * MS_PER_DAY).toISOString(),
|
|
93
|
+
policy: '30_day',
|
|
94
|
+
};
|
|
95
|
+
fs.writeFileSync(tagPath, JSON.stringify(tag), 'utf8');
|
|
96
|
+
}
|
|
97
|
+
tagged.push(file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { deleted, tagged };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sweep all intermediates whose .retention expiry has passed.
|
|
107
|
+
* Safe to run as a nightly cron.
|
|
108
|
+
*
|
|
109
|
+
* @param {{ filesDir: string, dryRun?: boolean }} opts
|
|
110
|
+
* @returns {{ swept: string[], skipped: number }}
|
|
111
|
+
*/
|
|
112
|
+
function sweepExpiredIntermediates({ filesDir, dryRun = false }) {
|
|
113
|
+
const swept = [];
|
|
114
|
+
let skipped = 0;
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
|
|
117
|
+
// Scan all subdirectories under filesDir (recursive — productions nest several levels deep)
|
|
118
|
+
const dirs = [];
|
|
119
|
+
const _walkDirs = (dir) => {
|
|
120
|
+
dirs.push(dir);
|
|
121
|
+
try {
|
|
122
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
123
|
+
if (entry.isDirectory()) _walkDirs(path.join(dir, entry.name));
|
|
124
|
+
}
|
|
125
|
+
} catch { /* permission error or disappeared — skip */ }
|
|
126
|
+
};
|
|
127
|
+
_walkDirs(filesDir);
|
|
128
|
+
|
|
129
|
+
for (const dir of dirs) {
|
|
130
|
+
if (!fs.existsSync(dir)) continue;
|
|
131
|
+
let entries;
|
|
132
|
+
try { entries = fs.readdirSync(dir); } catch { continue; }
|
|
133
|
+
|
|
134
|
+
for (const f of entries) {
|
|
135
|
+
if (!f.endsWith('.retention')) continue;
|
|
136
|
+
const tagPath = path.join(dir, f);
|
|
137
|
+
const filePath = tagPath.replace(/\.retention$/, '');
|
|
138
|
+
try {
|
|
139
|
+
const tag = JSON.parse(fs.readFileSync(tagPath, 'utf8'));
|
|
140
|
+
const expires = new Date(tag.expires_at).getTime();
|
|
141
|
+
if (now >= expires) {
|
|
142
|
+
if (!dryRun) {
|
|
143
|
+
_safeUnlink(filePath);
|
|
144
|
+
_safeUnlink(tagPath);
|
|
145
|
+
}
|
|
146
|
+
swept.push(filePath);
|
|
147
|
+
} else {
|
|
148
|
+
skipped++;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Corrupt tag — delete both to be safe
|
|
152
|
+
if (!dryRun) { _safeUnlink(tagPath); _safeUnlink(filePath); }
|
|
153
|
+
swept.push(filePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { swept, skipped };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Classify a file path by its retention policy.
|
|
163
|
+
* @param {string} filePath
|
|
164
|
+
* @returns {'permanent'|'30_day'|'temp'|'unknown'}
|
|
165
|
+
*/
|
|
166
|
+
function classifyRetention(filePath) {
|
|
167
|
+
if (PERMANENT_PATTERNS.some(p => p.test(filePath))) return 'permanent';
|
|
168
|
+
if (TEMP_PATTERNS.some(p => p.test(filePath))) return 'temp';
|
|
169
|
+
if (INTERMEDIATE_PATTERNS.some(p => p.test(filePath))) return '30_day';
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function _listFiles(dir) {
|
|
176
|
+
try {
|
|
177
|
+
return fs.readdirSync(dir)
|
|
178
|
+
.map(f => path.join(dir, f))
|
|
179
|
+
.filter(f => {
|
|
180
|
+
try { return fs.statSync(f).isFile(); } catch { return false; }
|
|
181
|
+
});
|
|
182
|
+
} catch { return []; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _safeUnlink(filePath) {
|
|
186
|
+
try { fs.unlinkSync(filePath); } catch { /* best-effort */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { applyRetentionRules, sweepExpiredIntermediates, classifyRetention };
|