@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,102 @@
1
+ /**
2
+ * Workflow D — Rendering
3
+ * Task #31: assets → TTS → audio mix → master video
4
+ *
5
+ * Returns: { productionId, masterPath, qcReport, sizeBytes }
6
+ */
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { StateMachine } = require('../pipeline/state-machine');
12
+ const { RetryPolicy } = require('../pipeline/retry-policy');
13
+ const { Telemetry } = require('../pipeline/telemetry');
14
+ const { VideoRenderer } = require('../video-renderer/renderer');
15
+ const { AudioMixer } = require('../audio-mixer/mixer');
16
+ const { QualityGate } = require('../quality-gate/gate');
17
+
18
+ const os = require('os');
19
+ const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
20
+ const STATE_DIR = `${FILES_DIR}/state`;
21
+
22
+ /**
23
+ * Render all scene assets into a final master video.
24
+ * @param {{ productionId: string, sceneAssets: object[], withMusic?: boolean, musicVolume?: number }} opts
25
+ * @returns {Promise<RenderResult>}
26
+ */
27
+ async function runRender({ productionId, sceneAssets, withMusic = true, musicVolume = 0.07, renderWidth = 1920, renderHeight = 1080 }) {
28
+ const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
29
+ const retryPol = new RetryPolicy({ maxAttempts: 2, baseDelayMs: 15000 });
30
+ const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
31
+ const renderer = new VideoRenderer({ filesDir: FILES_DIR });
32
+ const mixer = new AudioMixer({ filesDir: FILES_DIR });
33
+ const qc = new QualityGate();
34
+
35
+ await sm.init();
36
+ await telemetry.init();
37
+ await sm.startStage(productionId, 'Video_Rendering');
38
+
39
+ // ── Phase 1: Render individual scenes ─────────────────────────────────
40
+ const sceneFiles = [];
41
+ for (const asset of sceneAssets) {
42
+ const idx = asset.sceneId - 1;
43
+ try {
44
+ const scenePath = renderer.renderScene({
45
+ sceneIndex: idx,
46
+ imagePath: asset.imagePath,
47
+ videoPath: asset.videoPath,
48
+ voicePath: asset.voicePath,
49
+ caption: asset.caption || '',
50
+ camera: asset.camera || 'static',
51
+ focalPoint: asset.focalPoint || null,
52
+ renderWidth,
53
+ renderHeight,
54
+ });
55
+ sceneFiles.push(scenePath);
56
+ } catch (err) {
57
+ console.error(`[Render] Scene ${idx} failed: ${err.message} — skipping`);
58
+ }
59
+ }
60
+
61
+ if (sceneFiles.length < 1) {
62
+ await sm.failStage(productionId, 'Video_Rendering', `No scenes rendered successfully`);
63
+ throw new Error(`No scenes rendered successfully`);
64
+ }
65
+ if (sceneFiles.length < 3) {
66
+ console.warn(`[Render] WARNING: only ${sceneFiles.length} scene(s) rendered — video will be short. Minimum 3 recommended.`);
67
+ }
68
+
69
+ // ── Phase 2: Assemble master ──────────────────────────────────────────
70
+ const masterPath = await retryPol.executeStage('Video_Rendering', async () =>
71
+ renderer.assembleMain({ productionId, sceneCount: sceneFiles.length, withMusic, musicVolume })
72
+ );
73
+
74
+ // ── Phase 3: Quality gate ─────────────────────────────────────────────
75
+ const qcReport = await qc.validateMaster(masterPath);
76
+ if (!qcReport.passed) {
77
+ const failed = qcReport.checks.filter(c => !c.passed).map(c => c.name).join(', ');
78
+ await sm.failStage(productionId, 'Video_Rendering', `QC failed: ${failed}`);
79
+ return { productionId, passed: false, qcReport, masterPath: null };
80
+ }
81
+
82
+ const stat = require('fs').statSync(masterPath);
83
+ const outputHash = crypto.createHash('sha256')
84
+ .update(`${productionId}:${stat.size}:${sceneFiles.length}`)
85
+ .digest('hex').slice(0, 16);
86
+
87
+ await sm.completeStage(productionId, 'Video_Rendering', {
88
+ outputHash,
89
+ metrics: { sceneCount: sceneFiles.length, sizeBytes: stat.size },
90
+ });
91
+ await telemetry.track({
92
+ productionId,
93
+ stage: 'Video_Rendering',
94
+ provider: 'ffmpeg',
95
+ operation: 'master_render',
96
+ units: Math.round(stat.size / 1024 / 1024),
97
+ });
98
+
99
+ return { productionId, masterPath, passed: true, qcReport, sizeBytes: stat.size, sceneCount: sceneFiles.length, outputHash };
100
+ }
101
+
102
+ module.exports = { runRender };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Workflow A — Research
3
+ *
4
+ * Generic topic researcher. Works for any content type: tech explainers,
5
+ * cooking tutorials, travel vlogs, educational shorts, etc.
6
+ *
7
+ * The caller (agentic IDE or CLI) controls persona, format and style.
8
+ * This module does NOT hardcode any domain or narrator persona.
9
+ *
10
+ * Can be called from:
11
+ * 1. MCP server: research_topic tool
12
+ * 2. CLI: node src/workflows/research.js --topic "..."
13
+ * 3. Worker API: full_pipeline job type
14
+ *
15
+ * Returns: { productionId, topic, claims[], myths[], angles[], hookIdeas[] }
16
+ */
17
+ 'use strict';
18
+
19
+ const crypto = require('crypto');
20
+ const { LLMProvider } = require('../pipeline/providers');
21
+ const { StateMachine } = require('../pipeline/state-machine');
22
+ const { RetryPolicy } = require('../pipeline/retry-policy');
23
+ const { Telemetry } = require('../pipeline/telemetry');
24
+ const { FactChecker } = require('../fact-checker/checker');
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
+ * Run the research workflow for a single topic.
32
+ *
33
+ * @param {string} topic
34
+ * @param {{
35
+ * claimCount?: number,
36
+ * productionId?: string,
37
+ * persona?: string, // e.g. "tech journalist", "travel blogger", "cooking instructor"
38
+ * format?: string, // e.g. "short-form reel", "long YouTube video", "explainer"
39
+ * style?: string, // e.g. "educational", "comedic", "documentary", "tutorial"
40
+ * audience?: string, // e.g. "beginners", "professionals", "general public"
41
+ * }} opts
42
+ * @returns {Promise<ResearchResult>}
43
+ */
44
+ async function runResearch(topic, {
45
+ claimCount = 10,
46
+ productionId = null,
47
+ persona = 'video content researcher',
48
+ format = 'video',
49
+ style = 'informative',
50
+ audience = 'general public',
51
+ } = {}) {
52
+ const llm = new LLMProvider();
53
+ const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
54
+ const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 10000 });
55
+ const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
56
+ const checker = new FactChecker({ llm });
57
+
58
+ await sm.init();
59
+ await telemetry.init();
60
+
61
+ const pid = productionId || await sm.createProduction({ topic, pipelineVersion: '2.0.0' });
62
+ await sm.startStage(pid, 'Research');
63
+
64
+ // ── Phase 1: Initial research ─────────────────────────────────────────────
65
+ const researchRaw = await retryPol.executeStage('Research', async () => {
66
+ const system = [
67
+ `You are a ${persona} specialising in ${format} content.`,
68
+ 'Output ONLY raw JSON. No markdown. No code fences. Start with { and end with }.',
69
+ ].join(' ');
70
+
71
+ const user = [
72
+ `TOPIC: ${topic}`,
73
+ `FORMAT: ${format}`,
74
+ `STYLE: ${style}`,
75
+ `AUDIENCE: ${audience}`,
76
+ '',
77
+ `Return ${claimCount} factual claims as JSON:`,
78
+ '{"topic":"...","claims":[{"claim":"...","confidence":"high|medium|low","safe_wording":"..."}],',
79
+ '"common_myths":["..."],"modern_comparisons":["..."],"story_angles":["..."],',
80
+ '"hook_ideas":["...","...","..."]}',
81
+ ].join('\n');
82
+
83
+ return llm.chat({ system, user, temperature: 0.7, maxTokens: 4000 });
84
+ });
85
+
86
+ let research;
87
+ try { research = LLMProvider.parseJson(researchRaw); }
88
+ catch (e) { throw new Error(`Research JSON parse failed: ${e.message}`); }
89
+ if (!Array.isArray(research.claims) || research.claims.length < 5) {
90
+ throw new Error(`Research returned too few claims: ${research.claims?.length}`);
91
+ }
92
+
93
+ // ── Phase 2: Fact-check all claims ───────────────────────────────────────
94
+ const checkedClaims = await Promise.all(
95
+ research.claims.map(async c => {
96
+ try {
97
+ const result = await checker.checkClaim(c, { context: topic });
98
+ return {
99
+ ...c,
100
+ factCheckResult: result.decision,
101
+ riskLevel: result.riskLevel,
102
+ requiresReview: result.requiresHumanReview,
103
+ sources: result.evidence || [],
104
+ };
105
+ } catch {
106
+ return { ...c, factCheckResult: 'UNVERIFIED', requiresReview: true };
107
+ }
108
+ })
109
+ );
110
+
111
+ const approved = checkedClaims.filter(c => !c.requiresReview);
112
+ const needsReview = checkedClaims.filter(c => c.requiresReview);
113
+
114
+ if (needsReview.length > 0) {
115
+ console.warn(`[Research] ${needsReview.length} claims flagged for review:`, needsReview.map(c => c.claim.slice(0, 60)));
116
+ }
117
+
118
+ const outputHash = crypto.createHash('sha256').update(researchRaw).digest('hex').slice(0, 16);
119
+ await sm.completeStage(pid, 'Research', { outputHash });
120
+ await telemetry.track({
121
+ productionId: pid,
122
+ stage: 'Research',
123
+ provider: process.env.PROVIDER_LLM || 'groq',
124
+ operation: 'research_generation',
125
+ units: Math.ceil(researchRaw.length / 4),
126
+ });
127
+
128
+ return {
129
+ productionId: pid,
130
+ topic,
131
+ claims: approved,
132
+ claimsForReview: needsReview,
133
+ myths: research.common_myths || [],
134
+ angles: research.story_angles || [],
135
+ hookIdeas: research.hook_ideas || [],
136
+ outputHash,
137
+ };
138
+ }
139
+
140
+ module.exports = { runResearch };
141
+
142
+ // ── CLI entrypoint ─────────────────────────────────────────────────────────
143
+ if (require.main === module) {
144
+ const args = process.argv.slice(2);
145
+ const tIdx = args.indexOf('--topic');
146
+ const topic = tIdx >= 0 ? args[tIdx + 1] : args[0];
147
+ if (!topic) { console.error('Usage: node src/workflows/research.js --topic "..."'); process.exit(1); }
148
+ runResearch(topic).then(r => {
149
+ console.log(JSON.stringify(r, null, 2));
150
+ }).catch(e => { console.error(e.message); process.exit(1); });
151
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Workflow B — Script
3
+ *
4
+ * Generic scriptwriter. Works for any video format, topic, style, or language.
5
+ * The caller controls persona, style, format and scene count.
6
+ * No hardcoded domain, character types, or visual aesthetic.
7
+ *
8
+ * Returns: { productionId, scenes[], shorts[], title, description,
9
+ * tags[], thumbnailPrompt, totalWords }
10
+ */
11
+ 'use strict';
12
+
13
+ const crypto = require('crypto');
14
+ const { LLMProvider } = require('../pipeline/providers');
15
+ const { StateMachine } = require('../pipeline/state-machine');
16
+ const { RetryPolicy } = require('../pipeline/retry-policy');
17
+ const { Telemetry } = require('../pipeline/telemetry');
18
+
19
+ const os = require('os');
20
+ const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
21
+ const STATE_DIR = `${FILES_DIR}/state`;
22
+
23
+ const ALLOWED_CAMERAS = ['zoom_in','zoom_out','pan_left','pan_right','static','auto'];
24
+ const ALLOWED_MOODS = ['tense','calm','energetic','mysterious','neutral','dramatic','humorous'];
25
+
26
+ /**
27
+ * Normalise a single scene from LLM output.
28
+ * Accepts both v2.0.0 (visual_description) and legacy (visual_action) fields.
29
+ * Character IDs are now free-form strings (no enforced enum).
30
+ */
31
+ function normaliseScene(raw, index) {
32
+ const sceneId = raw.scene_id || raw.n || (index + 1);
33
+ const visDesc = String(raw.visual_description || raw.visual_action || 'scene visual').trim();
34
+ const char = raw.character || {};
35
+ const pos = char.position || {};
36
+ const fp = raw.focal_point || {};
37
+ const meta = raw.metadata || {};
38
+
39
+ return {
40
+ scene_id: sceneId,
41
+ schema_version: '2.0.0',
42
+ narration: String(raw.narration || '').trim(),
43
+ caption: String(raw.caption || '').toUpperCase().trim().slice(0, 40),
44
+ visual_description: visDesc,
45
+ // character is now fully open — character_id is any string the caller provides
46
+ character: {
47
+ character_id: String(char.character_id || 'none'),
48
+ pose: String(char.pose || 'none'),
49
+ emotion: String(char.emotion || 'neutral'),
50
+ position: {
51
+ x: typeof pos.x === 'number' ? Math.max(0, Math.min(1, pos.x)) : 0.5,
52
+ y: typeof pos.y === 'number' ? Math.max(0, Math.min(1, pos.y)) : 0.8,
53
+ scale: typeof pos.scale === 'number' ? Math.max(0.5, Math.min(2, pos.scale)) : 1.0,
54
+ },
55
+ },
56
+ focal_point: {
57
+ x: typeof fp.x === 'number' ? Math.max(0, Math.min(1, fp.x)) : 0.5,
58
+ y: typeof fp.y === 'number' ? Math.max(0, Math.min(1, fp.y)) : 0.35,
59
+ weight: typeof fp.weight === 'number' ? Math.max(0, Math.min(1, fp.weight)) : 0.8,
60
+ },
61
+ overlay: raw.overlay || { type: 'none' },
62
+ sound_effects: Array.isArray(raw.sound_effects) ? raw.sound_effects : [],
63
+ music_mood: ALLOWED_MOODS.includes(raw.music_mood) ? raw.music_mood : 'neutral',
64
+ camera_hint: ALLOWED_CAMERAS.includes(raw.camera_hint) ? raw.camera_hint :
65
+ ALLOWED_CAMERAS.includes(raw.camera) ? raw.camera : 'auto',
66
+ // Caller-supplied media paths — set these to skip AI generation for a scene
67
+ image_path: raw.image_path || null,
68
+ video_path: raw.video_path || null,
69
+ audio_path: raw.audio_path || null,
70
+ metadata: {
71
+ importance: meta.importance || 'medium',
72
+ hook_potential: typeof meta.hook_potential === 'number' ? meta.hook_potential : 0.5,
73
+ standalone: typeof meta.standalone === 'boolean' ? meta.standalone : false,
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Run the script generation workflow.
80
+ *
81
+ * @param {{
82
+ * productionId: string,
83
+ * claims: object[],
84
+ * topic: string,
85
+ * style?: string, // e.g. "educational", "comedic", "documentary", "tutorial", "vlog"
86
+ * persona?: string, // who the narrator is, e.g. "travel blogger", "tech reviewer"
87
+ * format?: string, // e.g. "YouTube short", "Instagram reel", "long-form tutorial", "explainer"
88
+ * language?: string, // BCP-47 code, default "en"
89
+ * sceneCount?: number, // target number of scenes (default 55 for long-form, fewer for shorts)
90
+ * targetDuration?: number, // target video duration in seconds (used as guidance for scene count)
91
+ * imageStyle?: string, // visual aesthetic for AI image prompts, e.g. "flat design", "photorealistic"
92
+ * }} opts
93
+ */
94
+ async function runScript({
95
+ productionId,
96
+ claims,
97
+ topic,
98
+ style = 'informative',
99
+ persona = 'video host',
100
+ format = 'YouTube video',
101
+ language = 'en',
102
+ sceneCount = 55,
103
+ targetDuration = null,
104
+ imageStyle = 'clean flat illustration, white background, bold lines',
105
+ }) {
106
+ const llm = new LLMProvider();
107
+ const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
108
+ const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 10000 });
109
+ const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
110
+
111
+ await sm.init();
112
+ await telemetry.init();
113
+ await sm.startStage(productionId, 'Script');
114
+
115
+ const facts = claims.slice(0, 8).map(c => c.safe_wording || c.claim).join('; ');
116
+
117
+ // If targetDuration is given, adjust scene count guidance (≈ 5s/scene).
118
+ // No ceiling — long-form documentaries, lectures, and podcasts may need hundreds of scenes.
119
+ const targetScenes = targetDuration ? Math.max(5, Math.round(targetDuration / 5)) : sceneCount;
120
+
121
+ const raw = await retryPol.executeStage('Script', async () => {
122
+ const system = [
123
+ `You are a ${persona} writing a ${format} script.`,
124
+ `Style: ${style}. Language: ${language}.`,
125
+ `Visual aesthetic for image prompts: ${imageStyle}.`,
126
+ 'Output ONLY raw JSON. No markdown. Start with { end with }.',
127
+ 'schema_version must be "2.0.0" on every scene.',
128
+ ].join(' ');
129
+
130
+ const user = [
131
+ `TOPIC: ${topic}`,
132
+ `FACTS: ${facts}`,
133
+ `FORMAT: ${format}`,
134
+ `STYLE: ${style}`,
135
+ `LANGUAGE: ${language}`,
136
+ '',
137
+ `Write exactly ${targetScenes} scenes (schema_version "2.0.0"). Each scene must have:`,
138
+ 'scene_id (integer), schema_version "2.0.0",',
139
+ 'narration (15-35 words in the specified language, spoken aloud),',
140
+ 'caption (3-6 words UPPERCASE, text overlay),',
141
+ 'visual_description (vivid description of what the scene looks like — used as image prompt),',
142
+ 'character { character_id (free text or "none"), pose, emotion, position {x,y,scale} },',
143
+ 'focal_point {x,y,weight},',
144
+ 'overlay { type: "prop"|"effect"|"text"|"none" },',
145
+ 'sound_effects [ { effect_id, timing: "start"|"middle"|"end", volume } ],',
146
+ 'music_mood: "tense"|"calm"|"energetic"|"mysterious"|"neutral"|"dramatic"|"humorous",',
147
+ 'camera_hint: "zoom_in"|"zoom_out"|"pan_left"|"pan_right"|"static"|"auto",',
148
+ 'metadata { importance, hook_potential (0-1), standalone (bool) }',
149
+ '',
150
+ 'RESPOND WITH EXACTLY:',
151
+ '{"topic":"...","title":"...","description":"...","tags":["..."],"thumbnail_prompt":"...",',
152
+ `"scenes":[...${targetScenes} scenes...],"shorts":[{"short_id":1,"title":"...","scene_range":[1,5],"hook_scene":1}]}`,
153
+ ].join('\n');
154
+
155
+ return llm.chat({ system, user, temperature: 1, maxTokens: 32000 });
156
+ });
157
+
158
+ let script;
159
+ try { script = LLMProvider.parseJson(raw); }
160
+ catch (e) { throw new Error(`Script parse failed: ${e.message}`); }
161
+ if (!Array.isArray(script.scenes) || script.scenes.length < 5) {
162
+ throw new Error(`Too few scenes: ${script.scenes?.length}`);
163
+ }
164
+
165
+ // No scene count ceiling — callers supply targetDuration / sceneCount and the
166
+ // pipeline renders however many scenes the script produces.
167
+ const scenes = script.scenes.map((s, i) => normaliseScene(s, i));
168
+
169
+ const totalWords = scenes.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0);
170
+ if (totalWords < 50) throw new Error(`Script too short: ${totalWords} words`);
171
+
172
+ const outputHash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
173
+ await sm.completeStage(productionId, 'Script', { outputHash });
174
+ await telemetry.track({
175
+ productionId,
176
+ stage: 'Script',
177
+ provider: process.env.PROVIDER_LLM || 'groq',
178
+ operation: 'script_generation',
179
+ units: Math.ceil(raw.length / 4),
180
+ });
181
+
182
+ return {
183
+ productionId,
184
+ scenes,
185
+ shorts: script.shorts || [],
186
+ title: script.title || topic,
187
+ description: script.description || '',
188
+ tags: script.tags || [],
189
+ thumbnailPrompt: script.thumbnail_prompt || '',
190
+ totalWords,
191
+ sceneCount: scenes.length,
192
+ outputHash,
193
+ };
194
+ }
195
+
196
+ module.exports = { runScript, normaliseScene };
197
+
198
+ // ── CLI entrypoint ─────────────────────────────────────────────────────────
199
+ if (require.main === module) {
200
+ const { runResearch } = require('./research');
201
+ const args = process.argv.slice(2);
202
+ const tIdx = args.indexOf('--topic');
203
+ const topic = tIdx >= 0 ? args[tIdx + 1] : args[0];
204
+ if (!topic) { console.error('Usage: node src/workflows/script.js --topic "..."'); process.exit(1); }
205
+ (async () => {
206
+ const res = await runResearch(topic);
207
+ const scr = await runScript({ productionId: res.productionId, claims: res.claims, topic });
208
+ console.log(JSON.stringify({ sceneCount: scr.scenes.length, totalWords: scr.totalWords, title: scr.title }, null, 2));
209
+ })().catch(e => { console.error(e.message); process.exit(1); });
210
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workflow D2 — Shorts
3
+ * Task #32: master → segment scoring → vertical render with saliency/focal-point → QC
4
+ *
5
+ * Returns: { productionId, shorts[{ shortPath, score, qcReport }] }
6
+ */
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { StateMachine } = require('../pipeline/state-machine');
12
+ const { RetryPolicy } = require('../pipeline/retry-policy');
13
+ const { Telemetry } = require('../pipeline/telemetry');
14
+ const { ShortsExtractor } = require('../shorts-extractor/extractor');
15
+ const { QualityGate } = require('../quality-gate/gate');
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
+ * Extract and render shorts from a master video.
23
+ * @param {{ productionId: string, masterPath: string, scenes: object[], maxShorts?: number }} opts
24
+ */
25
+ async function runShorts({ productionId, masterPath, scenes = [], maxShorts = 3 }) {
26
+ const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
27
+ const retryPol = new RetryPolicy({ maxAttempts: 2, baseDelayMs: 10000 });
28
+ const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
29
+ const extractor = new ShortsExtractor({ filesDir: FILES_DIR });
30
+ const qc = new QualityGate();
31
+
32
+ await sm.init();
33
+ await telemetry.init();
34
+ await sm.startStage(productionId, 'Shorts_Extraction');
35
+
36
+ // Build scene metadata map for focal-point-aware cropping
37
+ const sceneMeta = Object.fromEntries(
38
+ scenes.map(s => [s.scene_id, {
39
+ focalPoint: s.focal_point,
40
+ hookPotential: s.metadata?.hook_potential || 0.5,
41
+ standalone: s.metadata?.standalone || false,
42
+ }])
43
+ );
44
+
45
+ // Discover candidate segments from master
46
+ let segments;
47
+ try {
48
+ segments = await retryPol.executeStage('Shorts_Extraction', async () =>
49
+ extractor.extractCandidates(masterPath, { sceneMeta, maxCandidates: maxShorts * 3 })
50
+ );
51
+ } catch (err) {
52
+ await sm.failStage(productionId, 'Shorts_Extraction', err.message);
53
+ throw err;
54
+ }
55
+
56
+ if (!segments || segments.length === 0) {
57
+ await sm.skipStage(productionId, 'Shorts_Extraction', 'No suitable short segments found');
58
+ return { productionId, shorts: [], skipped: true };
59
+ }
60
+
61
+ // Render top-N shorts as 9:16 vertical videos
62
+ const shorts = [];
63
+ const topSegments = segments.slice(0, maxShorts);
64
+
65
+ for (let i = 0; i < topSegments.length; i++) {
66
+ const seg = topSegments[i];
67
+ const shortPath = path.join(FILES_DIR, `final_short_${productionId}_${i + 1}.mp4`);
68
+
69
+ try {
70
+ await retryPol.executeStage('Shorts_Extraction', async () =>
71
+ extractor.renderShort(masterPath, seg, shortPath)
72
+ );
73
+
74
+ const qcReport = await qc.validateShort(shortPath);
75
+ const stat = require('fs').statSync(shortPath);
76
+
77
+ shorts.push({
78
+ index: i + 1,
79
+ shortPath,
80
+ score: seg.score,
81
+ startSec: seg.startSec,
82
+ endSec: seg.endSec,
83
+ sizeBytes: stat.size,
84
+ passed: qcReport.passed,
85
+ qcReport,
86
+ });
87
+
88
+ await telemetry.track({
89
+ productionId,
90
+ stage: 'Shorts_Extraction',
91
+ provider: 'ffmpeg',
92
+ operation: 'short_render',
93
+ units: 1,
94
+ });
95
+ } catch (err) {
96
+ console.error(`[Shorts] Short ${i + 1} render failed: ${err.message}`);
97
+ }
98
+ }
99
+
100
+ const passedShorts = shorts.filter(s => s.passed);
101
+ const outputHash = crypto.createHash('sha256')
102
+ .update(passedShorts.map(s => s.shortPath).join(','))
103
+ .digest('hex').slice(0, 16);
104
+
105
+ await sm.completeStage(productionId, 'Shorts_Extraction', {
106
+ outputHash,
107
+ metrics: { total: shorts.length, passed: passedShorts.length },
108
+ });
109
+
110
+ return { productionId, shorts: passedShorts, allShorts: shorts, outputHash };
111
+ }
112
+
113
+ module.exports = { runShorts };