@amg555/frameforge-mcp 2.4.10 → 2.7.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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +22 -4
  2. package/CHANGELOG.md +174 -0
  3. package/CLAUDE.md +192 -9
  4. package/README.md +47 -6
  5. package/output/harness_test_1794418727139/frames/f000000.jpg +1 -0
  6. package/package.json +4 -3
  7. package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
  8. package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
  9. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
  10. package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
  11. package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
  12. package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
  13. package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
  14. package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
  15. package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
  16. package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
  17. package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
  18. package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
  19. package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
  20. package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
  21. package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
  22. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
  23. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
  24. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
  25. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
  26. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
  27. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
  28. package/scripts/deep-validate.js +52 -2
  29. package/src/audio-mixer/mixer.js +594 -63
  30. package/src/cli/doctor.js +36 -2
  31. package/src/cli/make.js +113 -9
  32. package/src/harness/bin/harness.mjs +127 -5
  33. package/src/harness/lib/capture.mjs +215 -192
  34. package/src/harness/lib/verify-quality.mjs +47 -7
  35. package/src/mcp-server/server.js +1015 -59
  36. package/src/skills/sync-upstream.js +290 -0
  37. package/src/utils/media-downloader.js +162 -0
  38. package/src/utils/transcribe.js +341 -0
  39. package/src/video-editor/editor.js +96 -11
  40. package/src/workflows/ingest.js +10 -0
@@ -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 (all curves coordinates must be in [0, 1] range)
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=tri:curve2=tri${outA}`
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.08,
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);
@@ -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}`);