@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,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow: Media Ingest
|
|
3
|
+
*
|
|
4
|
+
* Builds a production-ready scenes.json from user-supplied media files
|
|
5
|
+
* WITHOUT any LLM, research, or AI image generation.
|
|
6
|
+
*
|
|
7
|
+
* This is the "editor" mode entry point — the user provides:
|
|
8
|
+
* - Video clips (B-roll, screen recordings, gameplay, talking-head, stock)
|
|
9
|
+
* - Images (graphics, slides, photos, AI-generated images)
|
|
10
|
+
* - Audio files (pre-recorded voiceovers, podcasts, ElevenLabs output)
|
|
11
|
+
* - Captions (per-scene text overlays)
|
|
12
|
+
* - A manifest (JSON or array — see below)
|
|
13
|
+
*
|
|
14
|
+
* Output: scenes.json compatible with runAssets() → runRender() pipeline.
|
|
15
|
+
*
|
|
16
|
+
* CLI usage:
|
|
17
|
+
* # Build scenes from a directory of clips
|
|
18
|
+
* node src/cli/make.js --mode edit-only --media-dir ./my-clips
|
|
19
|
+
*
|
|
20
|
+
* # Build from an explicit manifest JSON
|
|
21
|
+
* node src/cli/make.js --mode edit-only --scenes-file ./my-scenes.json
|
|
22
|
+
*
|
|
23
|
+
* # Supply a ready-made script JSON (skip research/script, keep AI images + TTS)
|
|
24
|
+
* node src/cli/make.js --script-file ./approved-script.json
|
|
25
|
+
*
|
|
26
|
+
* Manifest format (scenes-file or media-dir auto-discovered):
|
|
27
|
+
* [
|
|
28
|
+
* {
|
|
29
|
+
* "narration": "The voice-over text for this scene.", // used for TTS if no audio_path
|
|
30
|
+
* "caption": "CAPTION TEXT", // on-screen overlay
|
|
31
|
+
* "video_path": "./clips/scene1.mp4", // OR
|
|
32
|
+
* "image_path": "./images/scene1.png", // OR generate (omit both)
|
|
33
|
+
* "audio_path": "./audio/scene1.mp3", // pre-recorded voiceover
|
|
34
|
+
* "camera_hint": "zoom_in", // zoom_in|zoom_out|pan_left|pan_right|static|auto
|
|
35
|
+
* "duration": 5.0, // seconds (optional — auto-detected)
|
|
36
|
+
* "music_mood": "energetic" // tense|calm|energetic|mysterious|neutral|dramatic|humorous
|
|
37
|
+
* }
|
|
38
|
+
* ]
|
|
39
|
+
*
|
|
40
|
+
* Auto-discovery (--media-dir):
|
|
41
|
+
* Scans the directory for video/image/audio files and builds scenes automatically.
|
|
42
|
+
* Pairs files with matching names: scene01.mp4 + scene01.mp3 + scene01.txt (caption).
|
|
43
|
+
* Sorts numerically by filename. One scene per matched group.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const fsp = require('fs').promises;
|
|
50
|
+
const path = require('path');
|
|
51
|
+
const crypto = require('crypto');
|
|
52
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const VIDEO_EXTS = new Set(['.mp4','.mov','.avi','.mkv','.webm','.m4v','.flv','.ts']);
|
|
55
|
+
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.webp','.gif','.bmp','.tiff']);
|
|
56
|
+
const AUDIO_EXTS = new Set(['.mp3','.m4a','.aac','.wav','.ogg','.flac','.opus']);
|
|
57
|
+
const TEXT_EXTS = new Set(['.txt','.caption','.srt']);
|
|
58
|
+
|
|
59
|
+
const ALLOWED_CAMERAS = ['zoom_in','zoom_out','pan_left','pan_right','static','auto'];
|
|
60
|
+
const ALLOWED_MOODS = ['tense','calm','energetic','mysterious','neutral','dramatic','humorous'];
|
|
61
|
+
|
|
62
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function extOf(filePath) {
|
|
65
|
+
return path.extname(filePath).toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isVideo(f) { return VIDEO_EXTS.has(extOf(f)); }
|
|
69
|
+
function isImage(f) { return IMAGE_EXTS.has(extOf(f)); }
|
|
70
|
+
function isAudio(f) { return AUDIO_EXTS.has(extOf(f)); }
|
|
71
|
+
function isText(f) { return TEXT_EXTS.has(extOf(f)); }
|
|
72
|
+
|
|
73
|
+
/** Strip extension and sort index from a filename, e.g. "scene_03_voice" → "scene_03" */
|
|
74
|
+
function baseStem(filePath) {
|
|
75
|
+
return path.basename(filePath).replace(/\.[^.]+$/, '').replace(/_(voice|audio|image|clip|video)$/i, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Natural-sort comparator for filenames */
|
|
79
|
+
function naturalSort(a, b) {
|
|
80
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Auto-discovery ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scan a directory and group media files into scenes by shared base name.
|
|
87
|
+
* e.g. scene01.mp4 + scene01.mp3 + scene01.txt → one scene
|
|
88
|
+
*/
|
|
89
|
+
function discoverScenesFromDir(mediaDir) {
|
|
90
|
+
const absDir = path.resolve(mediaDir);
|
|
91
|
+
if (!fs.existsSync(absDir)) {
|
|
92
|
+
throw new Error(`Media directory not found: ${absDir}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const files = fs.readdirSync(absDir)
|
|
96
|
+
.filter(f => {
|
|
97
|
+
const ext = path.extname(f).toLowerCase();
|
|
98
|
+
return VIDEO_EXTS.has(ext) || IMAGE_EXTS.has(ext) || AUDIO_EXTS.has(ext) || TEXT_EXTS.has(ext);
|
|
99
|
+
})
|
|
100
|
+
.sort(naturalSort);
|
|
101
|
+
|
|
102
|
+
// Group by stem
|
|
103
|
+
const groups = {};
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const stem = baseStem(path.join(absDir, file));
|
|
106
|
+
if (!groups[stem]) groups[stem] = {};
|
|
107
|
+
const full = path.join(absDir, file);
|
|
108
|
+
const ext = extOf(full);
|
|
109
|
+
if (VIDEO_EXTS.has(ext)) groups[stem].video = full;
|
|
110
|
+
else if (IMAGE_EXTS.has(ext)) groups[stem].image = full;
|
|
111
|
+
else if (AUDIO_EXTS.has(ext)) groups[stem].audio = full;
|
|
112
|
+
else if (TEXT_EXTS.has(ext)) {
|
|
113
|
+
try { groups[stem].caption = fs.readFileSync(full, 'utf8').trim(); } catch {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sorted = Object.entries(groups).sort(([a], [b]) => naturalSort(a, b));
|
|
118
|
+
if (sorted.length === 0) {
|
|
119
|
+
throw new Error(`No media files found in: ${absDir}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sorted.map(([stem, g], i) => ({
|
|
123
|
+
scene_id: i + 1,
|
|
124
|
+
narration: g.caption || '',
|
|
125
|
+
caption: (g.caption || '').toUpperCase().slice(0, 40),
|
|
126
|
+
video_path: g.video || null,
|
|
127
|
+
image_path: g.image || null,
|
|
128
|
+
audio_path: g.audio || null,
|
|
129
|
+
camera_hint: 'auto',
|
|
130
|
+
music_mood: 'neutral',
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Manifest normalizer ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalise a raw scene from any input format to the FrameForge scene schema.
|
|
138
|
+
* Validates media file paths exist. Resolves relative paths against baseDir.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} raw Raw scene from user manifest
|
|
141
|
+
* @param {number} index 0-based index
|
|
142
|
+
* @param {string} baseDir Base directory for relative path resolution
|
|
143
|
+
* @returns {object} Normalised scene
|
|
144
|
+
*/
|
|
145
|
+
function normaliseIngestScene(raw, index, baseDir = process.cwd()) {
|
|
146
|
+
const sceneId = raw.scene_id ?? raw.n ?? (index + 1);
|
|
147
|
+
|
|
148
|
+
function resolveMedia(p) {
|
|
149
|
+
if (!p) return null;
|
|
150
|
+
const resolved = path.isAbsolute(p) ? p : path.resolve(baseDir, p);
|
|
151
|
+
if (!fs.existsSync(resolved)) {
|
|
152
|
+
console.warn(` ⚠️ Scene ${sceneId}: media file not found: ${resolved}`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const videoPath = resolveMedia(raw.video_path || raw.videoPath || raw.clip);
|
|
159
|
+
const imagePath = resolveMedia(raw.image_path || raw.imagePath || raw.image);
|
|
160
|
+
const audioPath = resolveMedia(raw.audio_path || raw.audioPath || raw.audio || raw.voiceover);
|
|
161
|
+
|
|
162
|
+
const narration = String(raw.narration || raw.text || raw.script || '').trim();
|
|
163
|
+
const caption = String(raw.caption || raw.overlay || narration.slice(0, 40) || '').toUpperCase().trim().slice(0, 40);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
scene_id: sceneId,
|
|
167
|
+
schema_version: '1.0.0',
|
|
168
|
+
narration,
|
|
169
|
+
caption,
|
|
170
|
+
// visual_description used by AI image generation — blank means no AI image needed
|
|
171
|
+
visual_description: String(raw.visual_description || raw.description || narration || '').trim(),
|
|
172
|
+
video_path: videoPath,
|
|
173
|
+
image_path: imagePath,
|
|
174
|
+
audio_path: audioPath,
|
|
175
|
+
camera_hint: ALLOWED_CAMERAS.includes(raw.camera_hint || raw.camera) ? (raw.camera_hint || raw.camera) : 'auto',
|
|
176
|
+
music_mood: ALLOWED_MOODS.includes(raw.music_mood) ? raw.music_mood : 'neutral',
|
|
177
|
+
// Pass through any extra fields the user provided
|
|
178
|
+
character: raw.character || { character_id: 'none', pose: 'none', emotion: 'none' },
|
|
179
|
+
focal_point: raw.focal_point || { x: 0.5, y: 0.5, weight: 0.8 },
|
|
180
|
+
overlay: raw.overlay || { type: 'none' },
|
|
181
|
+
sound_effects: Array.isArray(raw.sound_effects) ? raw.sound_effects : [],
|
|
182
|
+
metadata: raw.metadata || { importance: 'medium', hook_potential: 0.5, standalone: false },
|
|
183
|
+
// Duration hint — used by renderer if provided (otherwise audio-driven)
|
|
184
|
+
duration: typeof raw.duration === 'number' ? raw.duration : null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Main ingest function ──────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Ingest user media into a production-ready scenes manifest.
|
|
192
|
+
*
|
|
193
|
+
* @param {{
|
|
194
|
+
* productionId?: string — reuse existing production ID or auto-generate
|
|
195
|
+
* scenesFile?: string — path to scenes.json / manifest JSON
|
|
196
|
+
* mediaDir?: string — directory to auto-discover clips from
|
|
197
|
+
* rawScenes?: object[] — inline scene array (programmatic use)
|
|
198
|
+
* outDir?: string — where to write the scenes manifest
|
|
199
|
+
* }} opts
|
|
200
|
+
* @returns {{ productionId: string, scenesFile: string, scenes: object[], sceneCount: number }}
|
|
201
|
+
*/
|
|
202
|
+
async function runIngest({ productionId, scenesFile, mediaDir, rawScenes, outDir } = {}) {
|
|
203
|
+
const filesDir = outDir || process.env.N8N_FILES_DIR || './output';
|
|
204
|
+
await fsp.mkdir(filesDir, { recursive: true });
|
|
205
|
+
|
|
206
|
+
// ── Step 1: Load/discover scenes ─────────────────────────────────────────
|
|
207
|
+
let rawList;
|
|
208
|
+
let baseDir = process.cwd();
|
|
209
|
+
|
|
210
|
+
if (rawScenes && Array.isArray(rawScenes) && rawScenes.length > 0) {
|
|
211
|
+
rawList = rawScenes;
|
|
212
|
+
console.log(`[Ingest] Using ${rawList.length} inline scenes`);
|
|
213
|
+
} else if (scenesFile) {
|
|
214
|
+
const absPath = path.resolve(process.cwd(), scenesFile);
|
|
215
|
+
if (!fs.existsSync(absPath)) throw new Error(`Scenes file not found: ${absPath}`);
|
|
216
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
217
|
+
const parsed = JSON.parse(content);
|
|
218
|
+
rawList = Array.isArray(parsed) ? parsed : (parsed.scenes || parsed.data || []);
|
|
219
|
+
baseDir = path.dirname(absPath);
|
|
220
|
+
console.log(`[Ingest] Loaded ${rawList.length} scene(s) from: ${absPath}`);
|
|
221
|
+
} else if (mediaDir) {
|
|
222
|
+
rawList = discoverScenesFromDir(mediaDir);
|
|
223
|
+
baseDir = path.resolve(mediaDir);
|
|
224
|
+
console.log(`[Ingest] Auto-discovered ${rawList.length} scene(s) from: ${mediaDir}`);
|
|
225
|
+
} else {
|
|
226
|
+
throw new Error('runIngest requires one of: scenesFile, mediaDir, or rawScenes');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (rawList.length === 0) {
|
|
230
|
+
throw new Error('No scenes found in the provided input');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Step 2: Normalise ─────────────────────────────────────────────────────
|
|
234
|
+
const scenes = rawList.map((raw, i) => normaliseIngestScene(raw, i, baseDir));
|
|
235
|
+
const validScenes = scenes.filter(s => s.narration || s.video_path || s.image_path || s.audio_path);
|
|
236
|
+
|
|
237
|
+
if (validScenes.length === 0) {
|
|
238
|
+
throw new Error('All scenes are empty — each scene needs at least narration, a video, an image, or an audio file');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Step 3: Assign productionId and register with state machine ──────────
|
|
242
|
+
const pid = productionId || `ingest_${crypto.randomBytes(4).toString('hex')}_${Date.now()}`;
|
|
243
|
+
|
|
244
|
+
// Register with the state machine so downstream stages (Assets, Render) can
|
|
245
|
+
// call startStage() without hitting "Production not found".
|
|
246
|
+
const stateDir = path.join(filesDir, 'state');
|
|
247
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
248
|
+
const stateFile = path.join(stateDir, `${pid}.json`);
|
|
249
|
+
if (!fs.existsSync(stateFile)) {
|
|
250
|
+
const record = {
|
|
251
|
+
productionId: pid,
|
|
252
|
+
status: 'QUEUED',
|
|
253
|
+
pipelineVersion: '2.0.0',
|
|
254
|
+
schemaVersion: '1.0.0',
|
|
255
|
+
topic: scenesFile || mediaDir || 'ingest',
|
|
256
|
+
createdAt: new Date().toISOString(),
|
|
257
|
+
startedAt: null,
|
|
258
|
+
completedAt: null,
|
|
259
|
+
configHash: crypto.createHash('sha256').update(pid).digest('hex').slice(0, 16),
|
|
260
|
+
stages: {},
|
|
261
|
+
metadata: {},
|
|
262
|
+
};
|
|
263
|
+
fs.writeFileSync(stateFile, JSON.stringify(record, null, 2), 'utf8');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Step 4: Write scenes manifest ────────────────────────────────────────
|
|
267
|
+
const outPath = path.join(filesDir, `${pid}-scenes.json`);
|
|
268
|
+
const manifest = {
|
|
269
|
+
productionId: pid,
|
|
270
|
+
createdAt: new Date().toISOString(),
|
|
271
|
+
sceneCount: validScenes.length,
|
|
272
|
+
mode: 'ingest',
|
|
273
|
+
scenes: validScenes,
|
|
274
|
+
};
|
|
275
|
+
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
276
|
+
console.log(`[Ingest] ✅ Scenes manifest saved: ${outPath}`);
|
|
277
|
+
|
|
278
|
+
// Print summary
|
|
279
|
+
const withVideo = validScenes.filter(s => s.video_path).length;
|
|
280
|
+
const withImage = validScenes.filter(s => s.image_path && !s.video_path).length;
|
|
281
|
+
const withAI = validScenes.filter(s => !s.video_path && !s.image_path).length;
|
|
282
|
+
const withAudio = validScenes.filter(s => s.audio_path).length;
|
|
283
|
+
const withTTS = validScenes.filter(s => !s.audio_path && s.narration).length;
|
|
284
|
+
console.log(`[Ingest] Scenes: ${validScenes.length} total`);
|
|
285
|
+
console.log(`[Ingest] Video clips: ${withVideo} | User images: ${withImage} | AI-generate: ${withAI}`);
|
|
286
|
+
console.log(`[Ingest] Pre-recorded audio: ${withAudio} | TTS needed: ${withTTS}`);
|
|
287
|
+
if (withAI > 0) console.log(`[Ingest] ℹ️ ${withAI} scene(s) will use AI image generation (remove --no-images to skip)`);
|
|
288
|
+
if (withTTS > 0) console.log(`[Ingest] ℹ️ ${withTTS} scene(s) will use TTS synthesis (remove --no-tts to skip)`);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
productionId: pid,
|
|
292
|
+
scenesFile: outPath,
|
|
293
|
+
scenes: validScenes,
|
|
294
|
+
sceneCount: validScenes.length,
|
|
295
|
+
stats: { withVideo, withImage, withAI, withAudio, withTTS },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = { runIngest, normaliseIngestScene, discoverScenesFromDir };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow E — Packaging
|
|
3
|
+
* Task #33: collect master + shorts + thumbnail + metadata + manifest + QC report
|
|
4
|
+
* into one complete production package ready for human review or publish.
|
|
5
|
+
*
|
|
6
|
+
* Returns: { productionId, packageDir, manifest }
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const fsSync = require('fs');
|
|
13
|
+
const fs = require('fs').promises;
|
|
14
|
+
const { StateMachine } = require('../pipeline/state-machine');
|
|
15
|
+
const { Telemetry } = require('../pipeline/telemetry');
|
|
16
|
+
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
|
|
19
|
+
const STATE_DIR = `${FILES_DIR}/state`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Assemble a production package from all rendered artefacts.
|
|
23
|
+
*
|
|
24
|
+
* @param {{
|
|
25
|
+
* productionId: string,
|
|
26
|
+
* masterPath: string,
|
|
27
|
+
* shorts?: object[],
|
|
28
|
+
* scenes: object[],
|
|
29
|
+
* title: string,
|
|
30
|
+
* description: string,
|
|
31
|
+
* tags?: string[],
|
|
32
|
+
* thumbnailPrompt?: string,
|
|
33
|
+
* qcReport: object,
|
|
34
|
+
* totalWords?: number,
|
|
35
|
+
* }} opts
|
|
36
|
+
*/
|
|
37
|
+
async function runPackaging({
|
|
38
|
+
productionId,
|
|
39
|
+
masterPath,
|
|
40
|
+
shorts = [],
|
|
41
|
+
scenes = [],
|
|
42
|
+
title = '',
|
|
43
|
+
description = '',
|
|
44
|
+
tags = [],
|
|
45
|
+
thumbnailPrompt = '',
|
|
46
|
+
qcReport,
|
|
47
|
+
totalWords = 0,
|
|
48
|
+
}) {
|
|
49
|
+
const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
|
|
50
|
+
const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
|
|
51
|
+
|
|
52
|
+
await sm.init();
|
|
53
|
+
await telemetry.init();
|
|
54
|
+
await sm.startStage(productionId, 'Packaging');
|
|
55
|
+
|
|
56
|
+
// Create package directory
|
|
57
|
+
const pkgDir = path.join(FILES_DIR, 'packages', productionId);
|
|
58
|
+
await fs.mkdir(pkgDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
// ── Copy master video ───────────────────────────────────────────────────
|
|
61
|
+
const masterDest = path.join(pkgDir, 'master.mp4');
|
|
62
|
+
await fs.copyFile(masterPath, masterDest).catch(() => {});
|
|
63
|
+
|
|
64
|
+
// ── Copy shorts ────────────────────────────────────────────────────────
|
|
65
|
+
const shortsMeta = [];
|
|
66
|
+
for (let i = 0; i < shorts.length; i++) {
|
|
67
|
+
const s = shorts[i];
|
|
68
|
+
if (!s.shortPath || !fsSync.existsSync(s.shortPath)) continue;
|
|
69
|
+
const shortDest = path.join(pkgDir, `short_${i + 1}.mp4`);
|
|
70
|
+
await fs.copyFile(s.shortPath, shortDest).catch(() => {});
|
|
71
|
+
shortsMeta.push({
|
|
72
|
+
index: i + 1,
|
|
73
|
+
path: shortDest,
|
|
74
|
+
score: s.score,
|
|
75
|
+
startSec: s.startSec,
|
|
76
|
+
endSec: s.endSec,
|
|
77
|
+
sizeBytes: s.sizeBytes,
|
|
78
|
+
qcPassed: s.passed,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Compute content hash ────────────────────────────────────────────────
|
|
83
|
+
const masterStat = fsSync.existsSync(masterDest) ? fsSync.statSync(masterDest) : { size: 0 };
|
|
84
|
+
const contentHash = crypto.createHash('sha256')
|
|
85
|
+
.update(`${productionId}:${masterStat.size}:${scenes.length}`)
|
|
86
|
+
.digest('hex').slice(0, 16);
|
|
87
|
+
|
|
88
|
+
// ── Build manifest ───────────────────────────────────────────────────────
|
|
89
|
+
const manifest = {
|
|
90
|
+
schemaVersion: '1.0.0',
|
|
91
|
+
productionId,
|
|
92
|
+
contentHash,
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
master: {
|
|
95
|
+
path: masterDest,
|
|
96
|
+
sizeBytes: masterStat.size,
|
|
97
|
+
qcPassed: qcReport?.passed ?? false,
|
|
98
|
+
},
|
|
99
|
+
shorts: shortsMeta,
|
|
100
|
+
sceneCount: scenes.length,
|
|
101
|
+
totalWords,
|
|
102
|
+
metadata: {
|
|
103
|
+
title,
|
|
104
|
+
description,
|
|
105
|
+
tags,
|
|
106
|
+
thumbnailPrompt,
|
|
107
|
+
},
|
|
108
|
+
qcSummary: {
|
|
109
|
+
passed: qcReport?.passed,
|
|
110
|
+
checks: (qcReport?.checks || []).map(c => ({ name: c.name, passed: c.passed, actual: c.actual })),
|
|
111
|
+
},
|
|
112
|
+
// Risk-based publish decision
|
|
113
|
+
publishDecision: _publishDecision(qcReport, scenes),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── Write manifest JSON ──────────────────────────────────────────────────
|
|
117
|
+
const manifestPath = path.join(pkgDir, 'manifest.json');
|
|
118
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
119
|
+
|
|
120
|
+
// ── Write video description file ────────────────────────────────────────
|
|
121
|
+
const descLines = [
|
|
122
|
+
title,
|
|
123
|
+
'',
|
|
124
|
+
description,
|
|
125
|
+
'',
|
|
126
|
+
tags.length ? `Tags: ${tags.slice(0, 10).join(', ')}` : '',
|
|
127
|
+
'',
|
|
128
|
+
'---',
|
|
129
|
+
'Produced with autonomous AI video pipeline.',
|
|
130
|
+
];
|
|
131
|
+
await fs.writeFile(path.join(pkgDir, 'description.md'), descLines.join('\n'), 'utf8');
|
|
132
|
+
|
|
133
|
+
await sm.completeStage(productionId, 'Packaging', {
|
|
134
|
+
outputHash: contentHash,
|
|
135
|
+
metrics: { sizeBytes: masterStat.size, shortCount: shortsMeta.length },
|
|
136
|
+
});
|
|
137
|
+
await telemetry.track({ productionId, stage: 'Packaging', provider: 'local', operation: 'package_assembly', units: 1 });
|
|
138
|
+
|
|
139
|
+
return { productionId, packageDir: pkgDir, manifestPath, manifest };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Determine publish decision based on QC report and scene risk levels.
|
|
144
|
+
* LOW risk + all gates pass → AUTO_PUBLISH
|
|
145
|
+
* MEDIUM risk → SAMPLE_REVIEW
|
|
146
|
+
* HIGH risk or failed gates → MANDATORY_REVIEW
|
|
147
|
+
*/
|
|
148
|
+
function _publishDecision(qcReport, scenes) {
|
|
149
|
+
if (!qcReport?.passed) return 'MANDATORY_REVIEW';
|
|
150
|
+
const highRisk = scenes.filter(s => s.metadata?.importance === 'critical').length;
|
|
151
|
+
if (highRisk === 0) return 'AUTO_PUBLISH';
|
|
152
|
+
if (highRisk <= 3) return 'SAMPLE_REVIEW';
|
|
153
|
+
return 'MANDATORY_REVIEW';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { runPackaging };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow F — Publishing
|
|
3
|
+
*
|
|
4
|
+
* Delivers a finished production package to one or more output channels.
|
|
5
|
+
*
|
|
6
|
+
* Built-in channels:
|
|
7
|
+
* file — copy the master + shorts to an output directory (default)
|
|
8
|
+
* webhook — POST a JSON summary to a configurable HTTP endpoint
|
|
9
|
+
*
|
|
10
|
+
* All platform-specific delivery (Telegram, YouTube, S3, etc.) is handled
|
|
11
|
+
* externally via the `webhook` channel or the `external_platform_call` MCP tool.
|
|
12
|
+
* This keeps the core pipeline free of platform SDK dependencies.
|
|
13
|
+
*
|
|
14
|
+
* Returns: { productionId, results, publishDecision }
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fsSync = require('fs');
|
|
20
|
+
const https = require('https');
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const { StateMachine } = require('../pipeline/state-machine');
|
|
23
|
+
const { RetryPolicy } = require('../pipeline/retry-policy');
|
|
24
|
+
const { Telemetry } = require('../pipeline/telemetry');
|
|
25
|
+
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
|
|
28
|
+
const STATE_DIR = `${FILES_DIR}/state`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{
|
|
32
|
+
* productionId: string,
|
|
33
|
+
* manifest: object,
|
|
34
|
+
* channels?: string[], // 'file' | 'webhook' — default ['file']
|
|
35
|
+
* outputDir?: string, // destination for 'file' channel (default: FILES_DIR/published)
|
|
36
|
+
* webhookUrl?: string, // override PUBLISH_WEBHOOK_URL env var
|
|
37
|
+
* force?: boolean,
|
|
38
|
+
* }} opts
|
|
39
|
+
*/
|
|
40
|
+
async function runPublishing({ productionId, manifest, channels = ['file'], outputDir, webhookUrl, force = false }) {
|
|
41
|
+
const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
|
|
42
|
+
const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 15000 });
|
|
43
|
+
const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
|
|
44
|
+
|
|
45
|
+
await sm.init();
|
|
46
|
+
await telemetry.init();
|
|
47
|
+
|
|
48
|
+
const decision = manifest?.publishDecision || 'AUTO_PUBLISH';
|
|
49
|
+
|
|
50
|
+
// Honour the publish decision gate
|
|
51
|
+
if (decision === 'MANDATORY_REVIEW' && !force) {
|
|
52
|
+
console.warn(`[Publishing] ${productionId} requires MANDATORY_REVIEW — skipping publish. Set force=true to override.`);
|
|
53
|
+
return { productionId, results: {}, publishDecision: decision, skipped: true, reason: 'mandatory_review_pending' };
|
|
54
|
+
}
|
|
55
|
+
if (decision === 'SAMPLE_REVIEW' && !force) {
|
|
56
|
+
console.warn(`[Publishing] ${productionId} flagged for SAMPLE_REVIEW — logging for human check.`);
|
|
57
|
+
await _logForReview(productionId, manifest);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await sm.startStage(productionId, 'Publishing');
|
|
61
|
+
|
|
62
|
+
const masterPath = manifest?.master?.path;
|
|
63
|
+
if (!masterPath || !fsSync.existsSync(masterPath)) {
|
|
64
|
+
throw new Error(`Master video not found at: ${masterPath}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const title = manifest?.metadata?.title || '';
|
|
68
|
+
const description = manifest?.metadata?.description || '';
|
|
69
|
+
const results = {};
|
|
70
|
+
|
|
71
|
+
// ── File channel ────────────────────────────────────────────────────────────
|
|
72
|
+
if (channels.includes('file')) {
|
|
73
|
+
results.file = await retryPol.executeStage('Publishing', async () => {
|
|
74
|
+
const dest = outputDir || path.join(FILES_DIR, 'published');
|
|
75
|
+
fsSync.mkdirSync(dest, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const ext = path.extname(masterPath) || '.mp4';
|
|
78
|
+
const safeName = (productionId || 'video').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
79
|
+
const destPath = path.join(dest, `${safeName}${ext}`);
|
|
80
|
+
|
|
81
|
+
fsSync.copyFileSync(masterPath, destPath);
|
|
82
|
+
|
|
83
|
+
// Copy shorts if present
|
|
84
|
+
const copiedShorts = [];
|
|
85
|
+
for (const short of (manifest?.shorts || [])) {
|
|
86
|
+
if (!short.path || !fsSync.existsSync(short.path)) continue;
|
|
87
|
+
const shortDest = path.join(dest, `${safeName}_short_${short.index || copiedShorts.length + 1}${path.extname(short.path) || '.mp4'}`);
|
|
88
|
+
fsSync.copyFileSync(short.path, shortDest);
|
|
89
|
+
copiedShorts.push(shortDest);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
destPath,
|
|
95
|
+
shorts: copiedShorts,
|
|
96
|
+
sizeBytes: fsSync.statSync(destPath).size,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Webhook channel ─────────────────────────────────────────────────────────
|
|
102
|
+
if (channels.includes('webhook')) {
|
|
103
|
+
const url = webhookUrl || process.env.PUBLISH_WEBHOOK_URL;
|
|
104
|
+
if (!url) {
|
|
105
|
+
results.webhook = { ok: false, reason: 'PUBLISH_WEBHOOK_URL not set — configure it in .env or pass webhookUrl' };
|
|
106
|
+
} else {
|
|
107
|
+
results.webhook = await retryPol.executeStage('Publishing', async () => {
|
|
108
|
+
const payload = JSON.stringify({
|
|
109
|
+
productionId,
|
|
110
|
+
title,
|
|
111
|
+
description,
|
|
112
|
+
masterPath,
|
|
113
|
+
sizeBytes: fsSync.statSync(masterPath).size,
|
|
114
|
+
shorts: (manifest?.shorts || []).map(s => ({ index: s.index, path: s.path })),
|
|
115
|
+
publishedAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const response = await _httpPost(url, payload, {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { ok: true, status: response.status, body: response.body.slice(0, 500) };
|
|
124
|
+
}).catch(err => ({ ok: false, error: err.message }));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await sm.completeStage(productionId, 'Publishing', {});
|
|
129
|
+
await sm.transitionProduction(productionId, 'SUCCEEDED').catch(() => {});
|
|
130
|
+
await telemetry.track({ productionId, stage: 'Publishing', provider: 'local', operation: 'video_delivery', units: 1 });
|
|
131
|
+
|
|
132
|
+
return { productionId, results, publishDecision: decision };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function _logForReview(productionId, manifest) {
|
|
136
|
+
const reviewLog = path.join(FILES_DIR, 'review_queue.ndjson');
|
|
137
|
+
const entry = JSON.stringify({ productionId, manifest, queuedAt: new Date().toISOString() }) + '\n';
|
|
138
|
+
fsSync.appendFileSync(reviewLog, entry, 'utf8');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Guard against SSRF: reject webhook URLs that resolve to RFC-1918 / link-local /
|
|
143
|
+
* loopback ranges. These are unroutable from the public internet but reachable
|
|
144
|
+
* from the server process, so a confused-deputy attack could exfiltrate cloud
|
|
145
|
+
* metadata or probe internal services.
|
|
146
|
+
*
|
|
147
|
+
* Only the hostname portion of the URL is checked — we don't perform a DNS
|
|
148
|
+
* resolution (that would be async and incomplete). Literal IP addresses in the
|
|
149
|
+
* URL cover the common metadata-endpoint pattern (169.254.169.254, 127.x, 10.x,
|
|
150
|
+
* 192.168.x, 172.16-31.x).
|
|
151
|
+
*/
|
|
152
|
+
function _assertSafeWebhookUrl(rawUrl) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try { parsed = new URL(rawUrl); }
|
|
155
|
+
catch { throw new Error(`Invalid webhook URL: ${rawUrl}`); }
|
|
156
|
+
|
|
157
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
158
|
+
throw new Error(`Webhook URL must use http: or https: — got: ${parsed.protocol}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const host = parsed.hostname.toLowerCase();
|
|
162
|
+
|
|
163
|
+
// Reject literal private / loopback / link-local IP addresses:
|
|
164
|
+
// 127.x (loopback), 10.x / 172.16-31.x / 192.168.x (RFC-1918),
|
|
165
|
+
// 169.254.x (link-local / AWS metadata), ::1 / fc / fe80 (IPv6 private).
|
|
166
|
+
const PRIVATE_RE = /^(127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|::1|fc[\da-f]{2}:|fe80:)/i;
|
|
167
|
+
|
|
168
|
+
if (PRIVATE_RE.test(host)) {
|
|
169
|
+
throw new Error(`Webhook URL resolves to a private/loopback address — SSRF blocked: ${host}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Reject bare "localhost" and common localhost aliases
|
|
173
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host === '0.0.0.0' || host === '[::1]') {
|
|
174
|
+
throw new Error(`Webhook URL must not target localhost — SSRF blocked: ${host}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Minimal HTTP/HTTPS POST helper — no external dependencies */
|
|
179
|
+
function _httpPost(url, body, headers = {}) {
|
|
180
|
+
_assertSafeWebhookUrl(url);
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const parsed = new URL(url);
|
|
183
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
184
|
+
const req = lib.request({
|
|
185
|
+
hostname: parsed.hostname,
|
|
186
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
187
|
+
path: parsed.pathname + parsed.search,
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers,
|
|
190
|
+
}, (res) => {
|
|
191
|
+
const chunks = [];
|
|
192
|
+
res.on('data', d => chunks.push(d));
|
|
193
|
+
res.on('end', () => {
|
|
194
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
195
|
+
if (res.statusCode >= 400) return reject(new Error(`Webhook HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
196
|
+
resolve({ status: res.statusCode, body });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
req.on('error', reject);
|
|
200
|
+
req.setTimeout(30000, () => { req.destroy(); reject(new Error('Webhook timeout')); });
|
|
201
|
+
req.write(body);
|
|
202
|
+
req.end();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { runPublishing, _assertSafeWebhookUrl };
|