@amg555/frameforge-mcp 2.4.10 → 2.7.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/.claude-plugin/marketplace.json +22 -4
- package/CHANGELOG.md +174 -0
- package/CLAUDE.md +192 -9
- package/README.md +47 -6
- package/package.json +4 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
- package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
- package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
- package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
- package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
- package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
- package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
- package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
- package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
- package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
- package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
- package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
- package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
- package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
- package/scripts/deep-validate.js +52 -2
- package/src/audio-mixer/mixer.js +594 -63
- package/src/cli/doctor.js +36 -2
- package/src/cli/make.js +113 -9
- package/src/harness/bin/harness.mjs +127 -5
- package/src/harness/lib/capture.mjs +215 -192
- package/src/harness/lib/verify-quality.mjs +47 -7
- package/src/mcp-server/server.js +1015 -59
- package/src/skills/sync-upstream.js +290 -0
- package/src/utils/media-downloader.js +162 -0
- package/src/utils/transcribe.js +341 -0
- package/src/video-editor/editor.js +96 -11
- package/src/workflows/ingest.js +10 -0
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +0 -464
- package/out/check/purity_report.json +0 -79
- package/out/frames/f000000.jpg +0 -0
- package/out/frames/f000001.jpg +0 -0
- package/out/frames/f000002.jpg +0 -0
- package/out/frames/f000003.jpg +0 -0
- package/out/frames/f000004.jpg +0 -0
- package/out/frames/f000005.jpg +0 -0
- package/out/frames/f000006.jpg +0 -0
- package/out/frames/f000007.jpg +0 -0
- package/out/frames/f000008.jpg +0 -0
- package/out/frames/f000009.jpg +0 -0
- package/out/frames/f000010.jpg +0 -0
- package/out/frames/f000011.jpg +0 -0
- package/out/frames/f000012.jpg +0 -0
- package/out/frames/f000013.jpg +0 -0
- package/out/frames/f000014.jpg +0 -0
- package/out/frames/f000015.jpg +0 -0
- package/out/frames/f000016.jpg +0 -0
- package/out/frames/f000017.jpg +0 -0
- package/out/frames/f000018.jpg +0 -0
- package/out/frames/f000019.jpg +0 -0
- package/out/frames/f000020.jpg +0 -0
- package/out/frames/f000021.jpg +0 -0
- package/out/frames/f000022.jpg +0 -0
- package/out/frames/f000023.jpg +0 -0
- package/out/frames/f000024.jpg +0 -0
- package/out/frames/f000025.jpg +0 -0
- package/out/frames/f000026.jpg +0 -0
- package/out/frames/f000027.jpg +0 -0
- package/out/frames/f000028.jpg +0 -0
- package/out/frames/f000029.jpg +0 -0
- package/out/frames/f000030.jpg +0 -0
- package/out/frames/f000031.jpg +0 -0
- package/out/frames/f000032.jpg +0 -0
- package/out/frames/f000033.jpg +0 -0
- package/out/frames/f000034.jpg +0 -0
- package/out/frames/f000035.jpg +0 -0
- package/out/frames/f000036.jpg +0 -0
- package/out/frames/f000037.jpg +0 -0
- package/out/frames/f000038.jpg +0 -0
- package/out/frames/f000039.jpg +0 -0
- package/out/frames/f000040.jpg +0 -0
- package/out/frames/f000041.jpg +0 -0
- package/out/frames/f000042.jpg +0 -0
- package/out/frames/f000043.jpg +0 -0
- package/out/frames/f000044.jpg +0 -0
- package/out/frames/f000045.jpg +0 -0
- package/out/frames/f000046.jpg +0 -0
- package/out/frames/f000047.jpg +0 -0
- package/out/frames/f000048.jpg +0 -0
- package/out/frames/f000049.jpg +0 -0
- package/out/frames/f000050.jpg +0 -0
- package/out/frames/f000051.jpg +0 -0
- package/out/frames/f000052.jpg +0 -0
- package/out/frames/f000053.jpg +0 -0
- package/out/frames/f000054.jpg +0 -0
- package/out/frames/f000055.jpg +0 -0
- package/out/frames/f000056.jpg +0 -0
- package/out/frames/f000057.jpg +0 -0
- package/out/frames/f000058.jpg +0 -0
- package/out/frames/f000059.jpg +0 -0
- package/src/harness/test/fixtures/broken-scene.html +0 -27
- package/src/harness/test/fixtures/reference-scene.html +0 -23
- package/src/harness/test/fixtures/sample-script.json +0 -18
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcribe.js — Speech Transcription & Silence Alignment via Whisper
|
|
3
|
+
*
|
|
4
|
+
* Provides word-level timestamps and speech pause detection for:
|
|
5
|
+
* 1. Precise speech-aligned video cuts (never slice words in half)
|
|
6
|
+
* 2. Karaoke/kinetic word caption generation
|
|
7
|
+
* 3. Intelligent speech-to-visual b-roll timing
|
|
8
|
+
* 4. Exporting SRT, NDJSON (engine.js), and JSON transcripts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
|
|
19
|
+
const TRANSCRIPTS_DIR = path.join(DEFAULT_FILES_DIR, 'transcripts');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format seconds into SRT timestamp format: HH:MM:SS,mmm
|
|
23
|
+
* @param {number} sec
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function formatTimestampSrt(sec) {
|
|
27
|
+
const s = Math.max(0, Number(sec) || 0);
|
|
28
|
+
const hrs = Math.floor(s / 3600);
|
|
29
|
+
const mins = Math.floor((s % 3600) / 60);
|
|
30
|
+
const secs = Math.floor(s % 60);
|
|
31
|
+
const ms = Math.floor((s - Math.floor(s)) * 1000);
|
|
32
|
+
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format transcript segments into SRT subtitle text
|
|
37
|
+
* @param {Array<{ start: number, end: number, text?: string, word?: string }>} segments
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function formatAsSrt(segments = []) {
|
|
41
|
+
if (!Array.isArray(segments)) return '';
|
|
42
|
+
return segments.map((seg, idx) => {
|
|
43
|
+
const start = formatTimestampSrt(seg.start);
|
|
44
|
+
const end = formatTimestampSrt(seg.end);
|
|
45
|
+
const text = (seg.text || seg.word || '').trim();
|
|
46
|
+
return `${idx + 1}\n${start} --> ${end}\n${text}\n`;
|
|
47
|
+
}).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format transcript segments into NDJSON (one JSON object per line)
|
|
52
|
+
* Directly consumed by engine.js captions() and cuePhrase()
|
|
53
|
+
* @param {Array<{ start: number, end: number, text?: string, word?: string }>} segments
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function formatAsNdjson(segments = []) {
|
|
57
|
+
if (!Array.isArray(segments)) return '';
|
|
58
|
+
return segments.map(s => JSON.stringify({
|
|
59
|
+
start: Number(s.start || 0),
|
|
60
|
+
end: Number(s.end || 0),
|
|
61
|
+
text: (s.text || s.word || '').trim(),
|
|
62
|
+
})).join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect available Whisper engines
|
|
67
|
+
*/
|
|
68
|
+
function detectWhisper() {
|
|
69
|
+
const isWin = process.platform === 'win32';
|
|
70
|
+
|
|
71
|
+
// 1. Check faster-whisper (CTranslate2 — fastest local inference)
|
|
72
|
+
try {
|
|
73
|
+
const fwCheck = spawnSync('python', ['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("faster_whisper") else 1)'], {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
shell: false,
|
|
77
|
+
});
|
|
78
|
+
if (fwCheck.status === 0) {
|
|
79
|
+
return { available: true, engine: 'faster-whisper' };
|
|
80
|
+
}
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
|
|
83
|
+
// 2. Python openai-whisper module check
|
|
84
|
+
try {
|
|
85
|
+
const pyCheck = spawnSync('python', ['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("whisper") else 1)'], {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
shell: false,
|
|
89
|
+
});
|
|
90
|
+
if (pyCheck.status === 0) {
|
|
91
|
+
return { available: true, engine: 'python-whisper' };
|
|
92
|
+
}
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
|
|
95
|
+
// 3. Check CLI binary 'whisper'
|
|
96
|
+
try {
|
|
97
|
+
const cliRes = spawnSync('whisper', ['--version'], { encoding: 'utf8', timeout: 5000, shell: isWin });
|
|
98
|
+
if (cliRes.status === 0) return { available: true, engine: 'whisper-cli' };
|
|
99
|
+
} catch (_) {}
|
|
100
|
+
|
|
101
|
+
// 4. Cloud fallback if OPENAI_API_KEY
|
|
102
|
+
if (process.env.OPENAI_API_KEY) {
|
|
103
|
+
return { available: true, engine: 'openai-api' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { available: false, engine: 'none' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect silence/pause segments using FFmpeg silencedetect filter
|
|
111
|
+
* (Deterministic, fast, works without any ML models)
|
|
112
|
+
*
|
|
113
|
+
* @param {string} audioPath
|
|
114
|
+
* @param {object} [opts]
|
|
115
|
+
* @param {number} [opts.noiseDb=-35]
|
|
116
|
+
* @param {number} [opts.minDuration=0.25]
|
|
117
|
+
* @returns {Array<{ start: number, end: number, duration: number }>}
|
|
118
|
+
*/
|
|
119
|
+
function detectPausesWithFFmpeg(audioPath, opts = {}) {
|
|
120
|
+
const noiseDb = opts.noiseDb != null ? opts.noiseDb : -35;
|
|
121
|
+
const minDur = opts.minDuration != null ? opts.minDuration : 0.25;
|
|
122
|
+
|
|
123
|
+
const res = spawnSync('ffmpeg', [
|
|
124
|
+
'-nostdin',
|
|
125
|
+
'-i', audioPath,
|
|
126
|
+
'-af', `silencedetect=n=${noiseDb}dB:d=${minDur}`,
|
|
127
|
+
'-f', 'null',
|
|
128
|
+
'-',
|
|
129
|
+
], { encoding: 'utf8', timeout: 30000, shell: false });
|
|
130
|
+
|
|
131
|
+
const output = (res.stderr || '') + (res.stdout || '');
|
|
132
|
+
const pauses = [];
|
|
133
|
+
|
|
134
|
+
const startRegex = /silence_start:\s*([0-9.]+)/g;
|
|
135
|
+
const endRegex = /silence_end:\s*([0-9.]+)\s*\|\s*silence_duration:\s*([0-9.]+)/g;
|
|
136
|
+
|
|
137
|
+
let sMatch;
|
|
138
|
+
const starts = [];
|
|
139
|
+
while ((sMatch = startRegex.exec(output)) !== null) {
|
|
140
|
+
starts.push(parseFloat(sMatch[1]));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let eMatch;
|
|
144
|
+
let idx = 0;
|
|
145
|
+
while ((eMatch = endRegex.exec(output)) !== null) {
|
|
146
|
+
const end = parseFloat(eMatch[1]);
|
|
147
|
+
const duration = parseFloat(eMatch[2]);
|
|
148
|
+
const start = starts[idx] != null ? starts[idx] : Math.max(0, end - duration);
|
|
149
|
+
pauses.push({ start, end, duration });
|
|
150
|
+
idx++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return pauses;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Snap a desired cut timestamp to the nearest speech pause/silence boundary
|
|
158
|
+
*
|
|
159
|
+
* @param {number} targetTime - Desired cut time in seconds
|
|
160
|
+
* @param {Array<{ start: number, end: number }>} pauses - Detected silence intervals
|
|
161
|
+
* @param {number} [tolerance=0.8] - Max seconds to shift the cut
|
|
162
|
+
* @returns {number} Adjusted cut timestamp
|
|
163
|
+
*/
|
|
164
|
+
function snapCutToPause(targetTime, pauses = [], tolerance = 0.8) {
|
|
165
|
+
if (!Array.isArray(pauses) || pauses.length === 0) return targetTime;
|
|
166
|
+
|
|
167
|
+
let bestTime = targetTime;
|
|
168
|
+
let minDiff = Infinity;
|
|
169
|
+
|
|
170
|
+
for (const p of pauses) {
|
|
171
|
+
// Middle of pause is the safest cut point
|
|
172
|
+
const mid = (p.start + p.end) / 2;
|
|
173
|
+
const diff = Math.abs(mid - targetTime);
|
|
174
|
+
|
|
175
|
+
if (diff <= tolerance && diff < minDiff) {
|
|
176
|
+
minDiff = diff;
|
|
177
|
+
bestTime = mid;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return Math.round(bestTime * 100) / 100;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Transcribe speech in audio/video to words and segments
|
|
186
|
+
*
|
|
187
|
+
* @param {string} mediaPath - Local audio or video file
|
|
188
|
+
* @param {object} [opts]
|
|
189
|
+
* @param {string} [opts.model='base'] - tiny|base|small|medium|large
|
|
190
|
+
* @param {string} [opts.language] - BCP-47 language hint
|
|
191
|
+
* @param {'ndjson'|'srt'|'json'|'all'} [opts.format] - Output format to generate
|
|
192
|
+
* @param {string} [opts.outputPath] - Optional output file path
|
|
193
|
+
* @returns {Promise<{
|
|
194
|
+
* text: string,
|
|
195
|
+
* duration: number,
|
|
196
|
+
* words: Array<{ word: string, start: number, end: number }>,
|
|
197
|
+
* segments: Array<{ id: number, start: number, end: number, text: string }>,
|
|
198
|
+
* pauses: Array<{ start: number, end: number, duration: number }>,
|
|
199
|
+
* files?: { srt?: string, ndjson?: string, json?: string }
|
|
200
|
+
* }>}
|
|
201
|
+
*/
|
|
202
|
+
async function transcribeAudio(mediaPath, opts = {}) {
|
|
203
|
+
if (!fs.existsSync(mediaPath)) {
|
|
204
|
+
throw new Error(`Media file not found for transcription: ${mediaPath}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const engine = detectWhisper();
|
|
208
|
+
const pauses = detectPausesWithFFmpeg(mediaPath);
|
|
209
|
+
let parsed = { text: '', duration: 0, words: [], segments: [] };
|
|
210
|
+
const isWin = process.platform === 'win32';
|
|
211
|
+
const cleanPath = mediaPath.replace(/\\/g, '/');
|
|
212
|
+
const modelName = opts.model || 'base';
|
|
213
|
+
|
|
214
|
+
// 1. faster-whisper execution
|
|
215
|
+
if (engine.engine === 'faster-whisper') {
|
|
216
|
+
const pyScript = `
|
|
217
|
+
import json, sys
|
|
218
|
+
from faster_whisper import WhisperModel
|
|
219
|
+
model = WhisperModel("${modelName}", device="auto", compute_type="auto")
|
|
220
|
+
segments, info = model.transcribe("${cleanPath}", word_timestamps=True${opts.language ? `, language="${opts.language}"` : ''})
|
|
221
|
+
words = []
|
|
222
|
+
seg_list = []
|
|
223
|
+
idx = 0
|
|
224
|
+
for s in segments:
|
|
225
|
+
seg_list.append({"id": idx, "start": round(s.start, 3), "end": round(s.end, 3), "text": s.text.strip()})
|
|
226
|
+
idx += 1
|
|
227
|
+
if s.words:
|
|
228
|
+
for w in s.words:
|
|
229
|
+
words.append({"word": w.word.strip(), "start": round(w.start, 3), "end": round(w.end, 3)})
|
|
230
|
+
out = {
|
|
231
|
+
"text": " ".join([s["text"] for s in seg_list]),
|
|
232
|
+
"segments": seg_list,
|
|
233
|
+
"words": words,
|
|
234
|
+
"duration": round(info.duration, 3) if hasattr(info, "duration") else 0
|
|
235
|
+
}
|
|
236
|
+
print(json.dumps(out))
|
|
237
|
+
`;
|
|
238
|
+
const res = spawnSync('python', ['-c', pyScript], { encoding: 'utf8', timeout: 180000, shell: false });
|
|
239
|
+
if (res.status === 0) {
|
|
240
|
+
try { parsed = JSON.parse(res.stdout); } catch (_) {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Python openai-whisper execution
|
|
245
|
+
if (!parsed.text && engine.engine === 'python-whisper') {
|
|
246
|
+
const pyScript = `
|
|
247
|
+
import whisper, json, sys
|
|
248
|
+
model = whisper.load_model("${modelName}")
|
|
249
|
+
result = model.transcribe("${cleanPath}", word_timestamps=True${opts.language ? `, language="${opts.language}"` : ''})
|
|
250
|
+
words = []
|
|
251
|
+
seg_list = []
|
|
252
|
+
for idx, s in enumerate(result.get("segments", [])):
|
|
253
|
+
seg_list.append({"id": idx, "start": round(s.get("start", 0), 3), "end": round(s.get("end", 0), 3), "text": s.get("text", "").strip()})
|
|
254
|
+
for w in s.get("words", []):
|
|
255
|
+
words.append({"word": w.get("word", "").strip(), "start": round(w.get("start", 0), 3), "end": round(w.get("end", 0), 3)})
|
|
256
|
+
out = {
|
|
257
|
+
"text": result.get("text", "").strip(),
|
|
258
|
+
"segments": seg_list,
|
|
259
|
+
"words": words
|
|
260
|
+
}
|
|
261
|
+
print(json.dumps(out))
|
|
262
|
+
`;
|
|
263
|
+
const res = spawnSync('python', ['-c', pyScript], { encoding: 'utf8', timeout: 180000, shell: isWin });
|
|
264
|
+
if (res.status === 0) {
|
|
265
|
+
try { parsed = JSON.parse(res.stdout); } catch (_) {}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3. Fallback: measure duration via ffprobe if whisper did not provide it
|
|
270
|
+
if (!parsed.duration || parsed.duration <= 0) {
|
|
271
|
+
try {
|
|
272
|
+
const probe = spawnSync('ffprobe', [
|
|
273
|
+
'-v', 'error',
|
|
274
|
+
'-show_entries', 'format=duration',
|
|
275
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
276
|
+
mediaPath,
|
|
277
|
+
], { encoding: 'utf8', timeout: 5000, shell: isWin });
|
|
278
|
+
parsed.duration = parseFloat((probe.stdout || '0').trim()) || 0;
|
|
279
|
+
} catch (_) {}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = {
|
|
283
|
+
text: parsed.text || '',
|
|
284
|
+
duration: parsed.duration || (parsed.segments?.length ? parsed.segments[parsed.segments.length - 1].end : 0),
|
|
285
|
+
words: parsed.words || [],
|
|
286
|
+
segments: parsed.segments || [],
|
|
287
|
+
pauses,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// 4. Output files generation if requested
|
|
291
|
+
if (opts.format || opts.outputPath) {
|
|
292
|
+
const targetFormat = opts.format || 'ndjson';
|
|
293
|
+
const outDir = opts.outputPath ? path.dirname(opts.outputPath) : TRANSCRIPTS_DIR;
|
|
294
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
295
|
+
const basename = path.basename(mediaPath, path.extname(mediaPath));
|
|
296
|
+
const files = {};
|
|
297
|
+
|
|
298
|
+
const segs = result.segments.length > 0
|
|
299
|
+
? result.segments
|
|
300
|
+
: result.words.map(w => ({ start: w.start, end: w.end, text: w.word }));
|
|
301
|
+
|
|
302
|
+
if (targetFormat === 'ndjson' || targetFormat === 'all') {
|
|
303
|
+
const ndjsonPath = (opts.outputPath && targetFormat === 'ndjson')
|
|
304
|
+
? opts.outputPath
|
|
305
|
+
: path.join(outDir, `${basename}.captions.ndjson`);
|
|
306
|
+
fs.writeFileSync(ndjsonPath, formatAsNdjson(segs), 'utf8');
|
|
307
|
+
files.ndjson = ndjsonPath;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (targetFormat === 'srt' || targetFormat === 'all') {
|
|
311
|
+
const srtPath = (opts.outputPath && targetFormat === 'srt')
|
|
312
|
+
? opts.outputPath
|
|
313
|
+
: path.join(outDir, `${basename}.subtitles.srt`);
|
|
314
|
+
fs.writeFileSync(srtPath, formatAsSrt(segs), 'utf8');
|
|
315
|
+
files.srt = srtPath;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (targetFormat === 'json' || targetFormat === 'all') {
|
|
319
|
+
const jsonPath = (opts.outputPath && targetFormat === 'json')
|
|
320
|
+
? opts.outputPath
|
|
321
|
+
: path.join(outDir, `${basename}.transcript.json`);
|
|
322
|
+
fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), 'utf8');
|
|
323
|
+
files.json = jsonPath;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
result.files = files;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
detectWhisper,
|
|
334
|
+
detectPausesWithFFmpeg,
|
|
335
|
+
snapCutToPause,
|
|
336
|
+
formatTimestampSrt,
|
|
337
|
+
formatAsSrt,
|
|
338
|
+
formatAsNdjson,
|
|
339
|
+
transcribeAudio,
|
|
340
|
+
};
|
|
341
|
+
|
|
@@ -24,12 +24,16 @@ const crypto = require('crypto');
|
|
|
24
24
|
|
|
25
25
|
const { AudioMixer } = require('../audio-mixer/mixer');
|
|
26
26
|
const { resolveFont } = require('../video-renderer/renderer');
|
|
27
|
+
const { buildCameraFilter } = require('../animation/animation-system');
|
|
27
28
|
|
|
28
29
|
const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
|
|
29
30
|
|
|
30
|
-
// Supported color grading presets
|
|
31
|
+
// Supported color grading presets — derived from lemo-opuscar film styles catalog
|
|
32
|
+
// All curves coordinates are in [0, 1] range. FFmpeg curves filter format:
|
|
33
|
+
// curves=r='in/out ...' where in/out are normalized [0,1] brightness values.
|
|
31
34
|
const COLOR_GRADES = {
|
|
32
35
|
none: null,
|
|
36
|
+
// ── Core presets ─────────────────────────────────────────────────────────
|
|
33
37
|
cinematic: "curves=m='0/0 0.25/0.2 0.75/0.8 1/1':r='0/0 0.5/0.53 1/1':b='0/0.03 0.5/0.48 1/0.97',eq=saturation=1.12:contrast=1.05",
|
|
34
38
|
warm: "curves=r='0/0 0.5/0.55 1/1':b='0/0 0.5/0.45 1/0.92',eq=saturation=1.08",
|
|
35
39
|
cool: "curves=b='0/0 0.5/0.56 1/1':r='0/0 0.5/0.46 1/0.94',eq=saturation=0.98",
|
|
@@ -38,16 +42,50 @@ const COLOR_GRADES = {
|
|
|
38
42
|
monochrome: "hue=s=0,eq=contrast=1.15",
|
|
39
43
|
vibrant: "eq=saturation=1.35:contrast=1.05",
|
|
40
44
|
cyberpunk: "curves=r='0/0 0.5/0.45 1/1':b='0/0.08 0.5/0.58 1/1',eq=saturation=1.3:contrast=1.1",
|
|
45
|
+
editorial_collage: "curves=vintage,eq=contrast=1.1:saturation=0.9",
|
|
46
|
+
editorial: "curves=vintage,eq=contrast=1.1:saturation=0.9",
|
|
47
|
+
product_launch: "curves=m='0/0 0.5/0.52 1/1':b='0/0 0.5/0.53 1/0.98',eq=contrast=1.08:saturation=1.05",
|
|
48
|
+
retrovhs: "curves=vintage,hue=s=0.9,eq=contrast=1.1",
|
|
49
|
+
anime: "curves=m='0/0 0.4/0.45 1/1',eq=saturation=1.4:contrast=1.12",
|
|
50
|
+
scifi: "curves=r='0/0 0.5/0.46 1/1':b='0/0 0.5/0.56 1/1',eq=contrast=1.15:saturation=1.1",
|
|
51
|
+
lofi: "curves=m='0/0.05 0.5/0.5 1/0.95',eq=saturation=0.9:contrast=0.95",
|
|
52
|
+
// ── lemo-opuscar extended film styles ────────────────────────────────────
|
|
53
|
+
// Cinematic 2.39:1 (Anamorphic) — deep teal shadows, copper highlights
|
|
54
|
+
cinematic_anamorphic: "curves=m='0/0 0.22/0.18 0.78/0.82 1/1':r='0/0 0.5/0.53 1/1':g='0/0 0.5/0.51 1/0.99':b='0/0.04 0.5/0.50 1/0.97',eq=saturation=1.18:contrast=1.07",
|
|
55
|
+
// Minimal Swiss Monochrome — stark contrast, no colour
|
|
56
|
+
swiss_mono: "hue=s=0,curves=m='0/0 0.15/0.05 0.5/0.5 0.85/0.95 1/1',eq=contrast=1.3",
|
|
57
|
+
// Anime Action — vivid, punchy, high saturation
|
|
58
|
+
anime_action: "curves=m='0/0 0.38/0.44 0.8/0.88 1/1',eq=saturation=1.45:contrast=1.15",
|
|
59
|
+
// Retro VHS 1980s — phosphor green tint, lifted blacks, slight cyan cast
|
|
60
|
+
retro_vhs_80s: "curves=r='0/0.02 0.5/0.46 1/0.94':g='0/0.03 0.5/0.52 1/1.0':b='0/0.05 0.5/0.54 1/0.98',eq=saturation=0.88:contrast=0.98",
|
|
61
|
+
// Lo-Fi Chill — pastel, soft, desaturated with warm cast
|
|
62
|
+
lofi_chill: "curves=r='0/0.04 0.5/0.52 1/0.96':b='0/0.02 0.5/0.50 1/0.94',eq=saturation=0.82:contrast=0.92",
|
|
63
|
+
// Broadcast News Financial — clinical blue, minimal warmth
|
|
64
|
+
broadcast_news: "curves=r='0/0 0.5/0.47 1/0.97':b='0/0 0.5/0.54 1/1.02',eq=saturation=1.05:contrast=1.08",
|
|
65
|
+
// 3D Isometric Blueprint — technical cobalt, washed midtones
|
|
66
|
+
blueprint: "curves=r='0/0 0.5/0.44 1/0.95':g='0/0 0.5/0.50 1/0.98':b='0/0.06 0.5/0.58 1/1.02'],eq=saturation=1.12:contrast=1.0",
|
|
67
|
+
// Vintage 16mm film — sepia warm grain simulation
|
|
68
|
+
film16mm: "curves=vintage,curves=r='0/0.02 0.5/0.53 1/0.97':b='0/0 0.5/0.44 1/0.90',eq=saturation=0.80:contrast=0.92",
|
|
69
|
+
// Product Launch — clean obsidian black, titanium silver, subtle blue accent
|
|
70
|
+
product_obsidian: "curves=m='0/0 0.3/0.22 0.7/0.78 1/1':b='0/0 0.5/0.52 1/1',eq=saturation=1.02:contrast=1.1",
|
|
41
71
|
};
|
|
42
72
|
|
|
43
|
-
// Supported transition types in FFmpeg xfade
|
|
73
|
+
// Supported transition types in FFmpeg xfade — including all known xfade variants
|
|
44
74
|
const VALID_TRANSITIONS = new Set([
|
|
45
75
|
'fade', 'wipeleft', 'wiperight', 'wipeup', 'wipedown',
|
|
46
76
|
'slideleft', 'slideright', 'slideup', 'slidedown',
|
|
47
77
|
'circlecrop', 'rectcrop', 'distance', 'fadeblack', 'fadewhite',
|
|
48
78
|
'radial', 'smoothleft', 'smoothright', 'smoothup', 'smoothdown',
|
|
49
79
|
'circleopen', 'circleclose', 'dissolve', 'pixelize', 'diagtl',
|
|
50
|
-
'diagtr', 'diagbl', 'diagbr', 'hlslice', 'hrslice', 'vuslice', 'vdslice'
|
|
80
|
+
'diagtr', 'diagbl', 'diagbr', 'hlslice', 'hrslice', 'vuslice', 'vdslice',
|
|
81
|
+
// Additional xfade variants (FFmpeg 5.0+)
|
|
82
|
+
'horzopen', 'horzclose', 'vertopen', 'vertclose',
|
|
83
|
+
'coverleft', 'coverright', 'coverup', 'coverdown',
|
|
84
|
+
'revealleft', 'revealright', 'revealup', 'revealdown',
|
|
85
|
+
'zoomin', 'fadegrays', 'squeezeh', 'squeezev',
|
|
86
|
+
'hblur', 'hlwind', 'hrwind', 'vuwind', 'vdwind',
|
|
87
|
+
// Aliases recognized by plan_video_edit style configs
|
|
88
|
+
'cut', 'none',
|
|
51
89
|
]);
|
|
52
90
|
|
|
53
91
|
/**
|
|
@@ -254,6 +292,9 @@ class VideoEditor {
|
|
|
254
292
|
voicePath = null,
|
|
255
293
|
colorGrade = null,
|
|
256
294
|
caption = '',
|
|
295
|
+
motion = null,
|
|
296
|
+
cameraHint = null,
|
|
297
|
+
punchIn = false,
|
|
257
298
|
orientation = 'landscape',
|
|
258
299
|
width,
|
|
259
300
|
height,
|
|
@@ -315,6 +356,15 @@ class VideoEditor {
|
|
|
315
356
|
vfParts.push(spFilters.videoFilter);
|
|
316
357
|
}
|
|
317
358
|
|
|
359
|
+
// Dynamic camera motion & punch-ins (zoompan push, drift, or pan)
|
|
360
|
+
const motionHint = (motion || cameraHint || (punchIn ? 'zoom_in' : null));
|
|
361
|
+
if (motionHint && motionHint !== 'static' && motionHint !== 'none') {
|
|
362
|
+
try {
|
|
363
|
+
const camFilter = buildCameraFilter(motionHint, { duration: finalDur, w: targetW, h: targetH });
|
|
364
|
+
if (camFilter) vfParts.push(camFilter);
|
|
365
|
+
} catch (_) {}
|
|
366
|
+
}
|
|
367
|
+
|
|
318
368
|
const colorFilter = buildColorFilter(colorGrade);
|
|
319
369
|
if (colorFilter) {
|
|
320
370
|
vfParts.push(colorFilter);
|
|
@@ -328,14 +378,13 @@ class VideoEditor {
|
|
|
328
378
|
vfParts.push('format=yuv420p');
|
|
329
379
|
|
|
330
380
|
// Build FFmpeg command arguments
|
|
331
|
-
const ffmpegArgs = ['-nostdin', '-y'];
|
|
381
|
+
const ffmpegArgs = ['-nostdin', '-y', '-threads', '2'];
|
|
332
382
|
|
|
333
383
|
// Fast input seek
|
|
334
384
|
if (startSec > 0) {
|
|
335
385
|
ffmpegArgs.push('-ss', startSec.toFixed(3));
|
|
336
386
|
}
|
|
337
387
|
|
|
338
|
-
ffmpegArgs.push('-t', durSec.toFixed(3));
|
|
339
388
|
ffmpegArgs.push('-i', videoPath);
|
|
340
389
|
|
|
341
390
|
const hasVoice = voicePath && fs.existsSync(voicePath);
|
|
@@ -379,6 +428,7 @@ class VideoEditor {
|
|
|
379
428
|
|
|
380
429
|
// Encoding parameters: broadcast quality BT.709
|
|
381
430
|
ffmpegArgs.push(
|
|
431
|
+
'-threads', '4',
|
|
382
432
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
383
433
|
'-profile:v', 'high', '-level', '4.1',
|
|
384
434
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
@@ -420,6 +470,7 @@ class VideoEditor {
|
|
|
420
470
|
clips = [],
|
|
421
471
|
transition = 'fade',
|
|
422
472
|
transitionDuration = 0.5,
|
|
473
|
+
splitCut = 'none',
|
|
423
474
|
outputPath = null,
|
|
424
475
|
} = {}) {
|
|
425
476
|
if (!Array.isArray(clips) || clips.length === 0) {
|
|
@@ -458,7 +509,7 @@ class VideoEditor {
|
|
|
458
509
|
|
|
459
510
|
if (!usesTransitions) {
|
|
460
511
|
// Fast concat filter without xfade
|
|
461
|
-
const ffmpegArgs = ['-nostdin', '-y'];
|
|
512
|
+
const ffmpegArgs = ['-nostdin', '-y', '-threads', '2'];
|
|
462
513
|
for (const c of normClips) {
|
|
463
514
|
ffmpegArgs.push('-i', c.path);
|
|
464
515
|
}
|
|
@@ -467,6 +518,7 @@ class VideoEditor {
|
|
|
467
518
|
ffmpegArgs.push(
|
|
468
519
|
'-filter_complex', filter,
|
|
469
520
|
'-map', '[vout]', '-map', '[aout]',
|
|
521
|
+
'-threads', '4',
|
|
470
522
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
471
523
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
472
524
|
'-pix_fmt', 'yuv420p',
|
|
@@ -482,7 +534,7 @@ class VideoEditor {
|
|
|
482
534
|
}
|
|
483
535
|
|
|
484
536
|
// Build xfade + acrossfade filter complex
|
|
485
|
-
const ffmpegArgs = ['-nostdin', '-y'];
|
|
537
|
+
const ffmpegArgs = ['-nostdin', '-y', '-threads', '2'];
|
|
486
538
|
for (const c of normClips) {
|
|
487
539
|
ffmpegArgs.push('-i', c.path);
|
|
488
540
|
}
|
|
@@ -520,8 +572,21 @@ class VideoEditor {
|
|
|
520
572
|
vFilters.push(
|
|
521
573
|
`${prevV}${nextV}xfade=transition=${tType}:duration=${tDur.toFixed(3)}:offset=${currentOffset.toFixed(3)}${outV}`
|
|
522
574
|
);
|
|
575
|
+
|
|
576
|
+
// Split cut (lemomo-ai/lemo-opuscar): J-cut (audio leads video) or L-cut (audio trails video)
|
|
577
|
+
const sCut = clipConfig.splitCut || splitCut || 'none';
|
|
578
|
+
let curve1 = 'tri';
|
|
579
|
+
let curve2 = 'tri';
|
|
580
|
+
if (sCut === 'j-cut') {
|
|
581
|
+
curve1 = 'qsin';
|
|
582
|
+
curve2 = 'hsin';
|
|
583
|
+
} else if (sCut === 'l-cut') {
|
|
584
|
+
curve1 = 'hsin';
|
|
585
|
+
curve2 = 'qsin';
|
|
586
|
+
}
|
|
587
|
+
|
|
523
588
|
aFilters.push(
|
|
524
|
-
`${prevA}${nextA}acrossfade=d=${tDur.toFixed(3)}:curve1
|
|
589
|
+
`${prevA}${nextA}acrossfade=d=${tDur.toFixed(3)}:curve1=${curve1}:curve2=${curve2}${outA}`
|
|
525
590
|
);
|
|
526
591
|
}
|
|
527
592
|
|
|
@@ -530,6 +595,7 @@ class VideoEditor {
|
|
|
530
595
|
ffmpegArgs.push(
|
|
531
596
|
'-filter_complex', filterComplex,
|
|
532
597
|
'-map', '[vout]', '-map', '[aout]',
|
|
598
|
+
'-threads', '4',
|
|
533
599
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
534
600
|
'-profile:v', 'high', '-level', '4.1',
|
|
535
601
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
@@ -575,7 +641,14 @@ class VideoEditor {
|
|
|
575
641
|
clips = [],
|
|
576
642
|
audioPath = null,
|
|
577
643
|
musicPath = null,
|
|
578
|
-
musicVolume = 0.
|
|
644
|
+
musicVolume = 0.12,
|
|
645
|
+
duckLevel = -18,
|
|
646
|
+
sfxCues = [],
|
|
647
|
+
roomProfile = null,
|
|
648
|
+
noiseGate = false,
|
|
649
|
+
stereoWiden = true,
|
|
650
|
+
platform = null,
|
|
651
|
+
targetLufs = -14,
|
|
579
652
|
orientation = 'landscape',
|
|
580
653
|
width,
|
|
581
654
|
height,
|
|
@@ -635,6 +708,8 @@ class VideoEditor {
|
|
|
635
708
|
const cVoice = c.audio_path || c.audioPath || null;
|
|
636
709
|
const cStart = c.start_time != null ? c.start_time : (c.startTime != null ? c.startTime : (c.trim_start != null ? c.trim_start : 0));
|
|
637
710
|
const cEnd = c.end_time != null ? c.end_time : (c.endTime != null ? c.endTime : (c.trim_end != null ? c.trim_end : null));
|
|
711
|
+
const cMotion = c.motion || c.camera_hint || c.cameraHint || null;
|
|
712
|
+
const cPunchIn = Boolean(c.punch_in || c.punchIn);
|
|
638
713
|
|
|
639
714
|
const edited = this.editClip({
|
|
640
715
|
videoPath: videoP,
|
|
@@ -646,6 +721,8 @@ class VideoEditor {
|
|
|
646
721
|
voicePath: cVoice,
|
|
647
722
|
colorGrade: cGrade,
|
|
648
723
|
caption: cCaption,
|
|
724
|
+
motion: cMotion,
|
|
725
|
+
punchIn: cPunchIn,
|
|
649
726
|
orientation,
|
|
650
727
|
width: targetW,
|
|
651
728
|
height: targetH,
|
|
@@ -693,17 +770,25 @@ class VideoEditor {
|
|
|
693
770
|
}
|
|
694
771
|
}
|
|
695
772
|
|
|
696
|
-
// Step 4: Mix background music with ducking if provided
|
|
773
|
+
// Step 4: Mix background music and professional SFX with ducking if provided
|
|
697
774
|
const outFinal = outputPath || path.join(
|
|
698
775
|
this.filesDir,
|
|
699
776
|
`video_edit_${crypto.randomBytes(4).toString('hex')}_${Date.now()}.mp4`
|
|
700
777
|
);
|
|
701
778
|
|
|
702
|
-
if (musicPath && fs.existsSync(musicPath)) {
|
|
779
|
+
if ((musicPath && fs.existsSync(musicPath)) || (Array.isArray(sfxCues) && sfxCues.length > 0)) {
|
|
703
780
|
this.mixer.mixVideo({
|
|
704
781
|
videoPath: currentVideoPath,
|
|
705
782
|
musicPath,
|
|
706
783
|
outputPath: outFinal,
|
|
784
|
+
musicVolume,
|
|
785
|
+
duckLevel,
|
|
786
|
+
sfxCues,
|
|
787
|
+
roomProfile,
|
|
788
|
+
noiseGate,
|
|
789
|
+
stereoWiden,
|
|
790
|
+
platform,
|
|
791
|
+
targetLufs,
|
|
707
792
|
});
|
|
708
793
|
} else {
|
|
709
794
|
fs.copyFileSync(currentVideoPath, outFinal);
|
package/src/workflows/ingest.js
CHANGED
|
@@ -49,6 +49,7 @@ const fs = require('fs');
|
|
|
49
49
|
const fsp = require('fs').promises;
|
|
50
50
|
const path = require('path');
|
|
51
51
|
const crypto = require('crypto');
|
|
52
|
+
const { isWebUrl, downloadMediaWithYtDlp } = require('../utils/media-downloader');
|
|
52
53
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
53
54
|
|
|
54
55
|
const VIDEO_EXTS = new Set(['.mp4','.mov','.avi','.mkv','.webm','.m4v','.flv','.ts']);
|
|
@@ -213,6 +214,15 @@ function normaliseIngestScene(raw, index, baseDir = process.cwd()) {
|
|
|
213
214
|
|
|
214
215
|
function resolveMedia(p) {
|
|
215
216
|
if (!p) return null;
|
|
217
|
+
if (isWebUrl(p)) {
|
|
218
|
+
try {
|
|
219
|
+
const dl = downloadMediaWithYtDlp(p);
|
|
220
|
+
return dl.localPath;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.warn(` ⚠️ Scene ${sceneId}: failed to download media URL ${p}: ${err.message}`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
216
226
|
const resolved = path.isAbsolute(p) ? p : path.resolve(baseDir, p);
|
|
217
227
|
if (!fs.existsSync(resolved)) {
|
|
218
228
|
console.warn(` ⚠️ Scene ${sceneId}: media file not found: ${resolved}`);
|