@amg555/frameforge-mcp 2.3.6 → 2.3.8

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.
@@ -0,0 +1,852 @@
1
+ /**
2
+ * VideoEditor — Autonomous Local Video Editor for FrameForge
3
+ *
4
+ * Full-featured local FFmpeg editor:
5
+ * - Media probing (ffprobe)
6
+ * - Timestamp parsing ("01:23.45", "83.45s", numeric seconds)
7
+ * - Trimming, cutting, speed ramping (PTS + atempo chaining)
8
+ * - Color grading (cinematic, warm, cool, vintage, bw, vibrant, cyberpunk)
9
+ * - Re-framing / aspect-ratio conforming (16:9 landscape, 9:16 portrait, 1:1 square)
10
+ * - Hardcoded / broadcast captions with HarfBuzz text shaping & multilingual support
11
+ * - Clip audio volume adjustment and voiceover / narration mixing
12
+ * - Multi-clip transitions using FFmpeg xfade (fade, dissolve, wipeleft, wiperight, slideup, slidedown, circleopen, etc.) and acrossfade
13
+ * - End-to-end assemble pipeline (editAndAssemble) matching user audio, b-roll, background music with ducking
14
+ * - Imperative operation dispatcher (applyOperations) for external_platform_call fallback
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { spawnSync } = require('child_process');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const fs = require('fs');
23
+ const crypto = require('crypto');
24
+
25
+ const { AudioMixer } = require('../audio-mixer/mixer');
26
+ const { resolveFont } = require('../video-renderer/renderer');
27
+
28
+ const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
29
+
30
+ // Supported color grading presets (all curves coordinates must be in [0, 1] range)
31
+ const COLOR_GRADES = {
32
+ none: null,
33
+ 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
+ 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
+ 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",
36
+ vintage: "curves=vintage,eq=contrast=0.95:saturation=0.85",
37
+ bw: "hue=s=0,eq=contrast=1.15",
38
+ monochrome: "hue=s=0,eq=contrast=1.15",
39
+ vibrant: "eq=saturation=1.35:contrast=1.05",
40
+ 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",
41
+ };
42
+
43
+ // Supported transition types in FFmpeg xfade
44
+ const VALID_TRANSITIONS = new Set([
45
+ 'fade', 'wipeleft', 'wiperight', 'wipeup', 'wipedown',
46
+ 'slideleft', 'slideright', 'slideup', 'slidedown',
47
+ 'circlecrop', 'rectcrop', 'distance', 'fadeblack', 'fadewhite',
48
+ 'radial', 'smoothleft', 'smoothright', 'smoothup', 'smoothdown',
49
+ 'circleopen', 'circleclose', 'dissolve', 'pixelize', 'diagtl',
50
+ 'diagtr', 'diagbl', 'diagbr', 'hlslice', 'hrslice', 'vuslice', 'vdslice'
51
+ ]);
52
+
53
+ /**
54
+ * Parse flexible timestamp into seconds (float).
55
+ * Supports: 12, "12", "12s", "12.5", "01:30", "01:30.5", "01:02:03", "01:02:03.500"
56
+ */
57
+ function parseTimestamp(val) {
58
+ if (val == null || val === '') return 0;
59
+ if (typeof val === 'number') return Number.isFinite(val) ? Math.max(0, val) : 0;
60
+ if (typeof val !== 'string') return 0;
61
+
62
+ const s = val.trim().replace(/s$/i, '');
63
+ if (!s) return 0;
64
+
65
+ if (!s.includes(':')) {
66
+ const num = parseFloat(s);
67
+ return Number.isFinite(num) ? Math.max(0, num) : 0;
68
+ }
69
+
70
+ const parts = s.split(':').map(p => parseFloat(p));
71
+ if (parts.some(p => !Number.isFinite(p))) return 0;
72
+
73
+ if (parts.length === 3) {
74
+ return Math.max(0, parts[0] * 3600 + parts[1] * 60 + parts[2]);
75
+ }
76
+ if (parts.length === 2) {
77
+ return Math.max(0, parts[0] * 60 + parts[1]);
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ /**
83
+ * Return color grading filter string for a given preset name.
84
+ */
85
+ function buildColorFilter(grade) {
86
+ if (!grade || typeof grade !== 'string') return null;
87
+ const key = grade.trim().toLowerCase().replace(/[\s-_]/g, '');
88
+ return COLOR_GRADES[key] || null;
89
+ }
90
+
91
+ /**
92
+ * Build PTS and atempo filters for a speed multiplier.
93
+ * FFmpeg atempo filter requires 0.5 <= value <= 2.0; chains filters if needed.
94
+ */
95
+ function buildSpeedFilters(speed = 1.0) {
96
+ const sp = Number.isFinite(speed) && speed > 0 ? speed : 1.0;
97
+ const videoFilter = `setpts=${(1 / sp).toFixed(6)}*PTS`;
98
+ if (Math.abs(sp - 1.0) < 0.001) {
99
+ return { speed: 1.0, videoFilter, audioFilter: '' };
100
+ }
101
+
102
+ let rem = sp;
103
+ const atempoFilters = [];
104
+ while (rem > 2.0) {
105
+ atempoFilters.push('atempo=2.0');
106
+ rem /= 2.0;
107
+ }
108
+ while (rem < 0.5) {
109
+ atempoFilters.push('atempo=0.5');
110
+ rem /= 0.5;
111
+ }
112
+ atempoFilters.push(`atempo=${rem.toFixed(4)}`);
113
+ return {
114
+ speed: sp,
115
+ videoFilter,
116
+ audioFilter: atempoFilters.join(','),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Build caption drawtext filter with HarfBuzz shaping and font fallback.
122
+ */
123
+ function buildCaptionFilter(caption, fontOverride = '') {
124
+ if (!caption || typeof caption !== 'string') return '';
125
+ const font = fontOverride || resolveFont(caption);
126
+ if (!font) return '';
127
+
128
+ const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(caption);
129
+ const safe = Array.from(
130
+ caption.replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '').replace(/\s+/g, ' ').trim()
131
+ ).slice(0, 50).join('');
132
+ if (!safe) return '';
133
+
134
+ const safeText = hasIndic ? safe : safe.toUpperCase();
135
+ const safeFont = font.replace(/\\/g, '/').replace(/:/g, '\\:');
136
+ const shaping = hasIndic ? ':text_shaping=1' : '';
137
+
138
+ return `drawtext=fontfile='${safeFont}'${shaping}:text='${safeText}':fontsize=64:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
139
+ }
140
+
141
+ class VideoEditor {
142
+ constructor({ filesDir = DEFAULT_FILES_DIR } = {}) {
143
+ this.filesDir = filesDir;
144
+ this.editDir = path.join(filesDir, 'edit_tmp');
145
+ this.mixer = new AudioMixer({ filesDir });
146
+ }
147
+
148
+ /**
149
+ * Probe media file using ffprobe.
150
+ * Returns metadata: duration, dimensions, fps, codecs, channels.
151
+ */
152
+ probeMedia(filePath) {
153
+ if (!filePath || !fs.existsSync(filePath)) {
154
+ throw new Error(`probeMedia failed: file does not exist (${filePath})`);
155
+ }
156
+
157
+ const result = spawnSync('ffprobe', [
158
+ '-v', 'quiet',
159
+ '-print_format', 'json',
160
+ '-show_format',
161
+ '-show_streams',
162
+ filePath,
163
+ ], { encoding: 'utf8', timeout: 30000 });
164
+
165
+ if (result.status !== 0) {
166
+ throw new Error(`ffprobe failed on ${filePath}: ${(result.stderr || '').slice(-300)}`);
167
+ }
168
+
169
+ let info;
170
+ try {
171
+ info = JSON.parse(result.stdout || '{}');
172
+ } catch (err) {
173
+ throw new Error(`Failed to parse ffprobe JSON for ${filePath}: ${err.message}`);
174
+ }
175
+
176
+ const streams = Array.isArray(info.streams) ? info.streams : [];
177
+ const format = info.format || {};
178
+
179
+ const vStream = streams.find(s => s.codec_type === 'video');
180
+ const aStream = streams.find(s => s.codec_type === 'audio');
181
+
182
+ let width = 0;
183
+ let height = 0;
184
+ let fps = 24;
185
+ let videoCodec = null;
186
+
187
+ if (vStream) {
188
+ width = parseInt(vStream.width || 0, 10);
189
+ height = parseInt(vStream.height || 0, 10);
190
+ videoCodec = vStream.codec_name || null;
191
+ const rateStr = vStream.r_frame_rate || vStream.avg_frame_rate || '';
192
+ if (rateStr.includes('/')) {
193
+ const [num, den] = rateStr.split('/').map(Number);
194
+ if (den && num) fps = parseFloat((num / den).toFixed(3));
195
+ } else if (parseFloat(rateStr)) {
196
+ fps = parseFloat(rateStr);
197
+ }
198
+ }
199
+
200
+ const duration = parseFloat(format.duration || vStream?.duration || aStream?.duration || 0) || 0;
201
+ const sizeBytes = parseInt(format.size || (fs.existsSync(filePath) ? fs.statSync(filePath).size : 0), 10);
202
+ const bitrate = parseInt(format.bit_rate || 0, 10);
203
+
204
+ return {
205
+ duration,
206
+ width,
207
+ height,
208
+ aspectRatio: height > 0 ? width / height : 0,
209
+ fps,
210
+ videoCodec,
211
+ hasVideo: Boolean(vStream),
212
+ hasAudio: Boolean(aStream),
213
+ audioCodec: aStream ? aStream.codec_name : null,
214
+ audioChannels: aStream ? parseInt(aStream.channels || 0, 10) : 0,
215
+ audioSampleRate: aStream ? parseInt(aStream.sample_rate || 0, 10) : 0,
216
+ bitrate,
217
+ sizeBytes,
218
+ formatName: format.format_name || null,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Edit a single clip: trim, speed-ramp, color-grade, reframe, caption, audio adjust/mix.
224
+ *
225
+ * @param {{
226
+ * videoPath: string,
227
+ * startTime?: string|number,
228
+ * trimStart?: string|number,
229
+ * endTime?: string|number,
230
+ * trimEnd?: string|number,
231
+ * duration?: string|number,
232
+ * speed?: number,
233
+ * clipVolume?: number,
234
+ * voicePath?: string,
235
+ * colorGrade?: string,
236
+ * caption?: string,
237
+ * orientation?: 'landscape'|'portrait'|'square',
238
+ * width?: number,
239
+ * height?: number,
240
+ * fps?: number,
241
+ * outputPath?: string,
242
+ * }} opts
243
+ * @returns {{ outputPath: string, duration: number, width: number, height: number, hasAudio: boolean }}
244
+ */
245
+ editClip({
246
+ videoPath,
247
+ startTime = 0,
248
+ trimStart,
249
+ endTime,
250
+ trimEnd,
251
+ duration,
252
+ speed = 1.0,
253
+ clipVolume = 1.0,
254
+ voicePath = null,
255
+ colorGrade = null,
256
+ caption = '',
257
+ orientation = 'landscape',
258
+ width,
259
+ height,
260
+ fps = 24,
261
+ outputPath = null,
262
+ } = {}) {
263
+ if (!videoPath || !fs.existsSync(videoPath)) {
264
+ throw new Error(`editClip failed: videoPath does not exist (${videoPath})`);
265
+ }
266
+
267
+ fs.mkdirSync(this.editDir, { recursive: true });
268
+
269
+ // Target dimensions
270
+ let targetW = width;
271
+ let targetH = height;
272
+ if (!targetW || !targetH) {
273
+ if (orientation === 'portrait') {
274
+ targetW = 1080;
275
+ targetH = 1920;
276
+ } else if (orientation === 'square') {
277
+ targetW = 1080;
278
+ targetH = 1080;
279
+ } else {
280
+ targetW = 1920;
281
+ targetH = 1080;
282
+ }
283
+ }
284
+
285
+ const startSec = parseTimestamp(trimStart != null ? trimStart : startTime);
286
+ const endSec = (trimEnd != null || endTime != null) ? parseTimestamp(trimEnd != null ? trimEnd : endTime) : null;
287
+ let durSec = duration != null ? parseTimestamp(duration) : null;
288
+
289
+ if (endSec != null && endSec > startSec && durSec == null) {
290
+ durSec = endSec - startSec;
291
+ }
292
+
293
+ const probe = this.probeMedia(videoPath);
294
+ if (!durSec) {
295
+ durSec = Math.max(1, (probe.duration || 5) - startSec);
296
+ }
297
+
298
+ const spFilters = buildSpeedFilters(speed);
299
+ // Calculated final duration after speed adjustment
300
+ const finalDur = Math.max(0.5, durSec / spFilters.speed);
301
+
302
+ const outPath = outputPath || path.join(
303
+ this.editDir,
304
+ `clip_${crypto.randomBytes(4).toString('hex')}_${Date.now()}.mp4`
305
+ );
306
+
307
+ // Build video filter
308
+ const vfParts = [
309
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase:flags=lanczos`,
310
+ `crop=${targetW}:${targetH}`,
311
+ `fps=${fps}`,
312
+ ];
313
+
314
+ if (spFilters.speed !== 1.0) {
315
+ vfParts.push(spFilters.videoFilter);
316
+ }
317
+
318
+ const colorFilter = buildColorFilter(colorGrade);
319
+ if (colorFilter) {
320
+ vfParts.push(colorFilter);
321
+ }
322
+
323
+ const captionFilter = buildCaptionFilter(caption);
324
+ if (captionFilter) {
325
+ vfParts.push(captionFilter);
326
+ }
327
+
328
+ vfParts.push('format=yuv420p');
329
+
330
+ // Build FFmpeg command arguments
331
+ const ffmpegArgs = ['-nostdin', '-y'];
332
+
333
+ // Fast input seek
334
+ if (startSec > 0) {
335
+ ffmpegArgs.push('-ss', startSec.toFixed(3));
336
+ }
337
+
338
+ ffmpegArgs.push('-t', durSec.toFixed(3));
339
+ ffmpegArgs.push('-i', videoPath);
340
+
341
+ const hasVoice = voicePath && fs.existsSync(voicePath);
342
+ if (hasVoice) {
343
+ ffmpegArgs.push('-i', voicePath);
344
+ }
345
+
346
+ const hasClipAudio = probe.hasAudio && clipVolume > 0;
347
+
348
+ // Filter complex setup
349
+ let filterComplex = '';
350
+ const vFilterStr = vfParts.join(',');
351
+
352
+ if (hasVoice && hasClipAudio) {
353
+ // Mix clip audio + voice audio
354
+ const aParts = [`[0:a]volume=${clipVolume.toFixed(2)}`];
355
+ if (spFilters.audioFilter) aParts.push(spFilters.audioFilter);
356
+ aParts.push('[ca]');
357
+
358
+ filterComplex = `[0:v]${vFilterStr}[vout];` +
359
+ `${aParts.join(',')};` +
360
+ `[1:a]volume=1.0[va];` +
361
+ `[ca][va]amix=inputs=2:duration=first:dropout_transition=2[aout]`;
362
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[vout]', '-map', '[aout]');
363
+ } else if (hasVoice && !hasClipAudio) {
364
+ // Voice only
365
+ filterComplex = `[0:v]${vFilterStr}[vout];[1:a]volume=1.0[aout]`;
366
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[vout]', '-map', '[aout]');
367
+ } else if (!hasVoice && hasClipAudio) {
368
+ // Clip audio only
369
+ const aParts = [`volume=${clipVolume.toFixed(2)}`];
370
+ if (spFilters.audioFilter) aParts.push(spFilters.audioFilter);
371
+ ffmpegArgs.push('-vf', vFilterStr);
372
+ ffmpegArgs.push('-af', aParts.join(','));
373
+ ffmpegArgs.push('-map', '0:v:0', '-map', '0:a:0');
374
+ } else {
375
+ // No audio from clip or voice: generate silent stereo audio
376
+ filterComplex = `[0:v]${vFilterStr}[vout];aevalsrc=0:d=${finalDur.toFixed(3)}:s=48000[aout]`;
377
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[vout]', '-map', '[aout]');
378
+ }
379
+
380
+ // Encoding parameters: broadcast quality BT.709
381
+ ffmpegArgs.push(
382
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
383
+ '-profile:v', 'high', '-level', '4.1',
384
+ '-color_range', 'tv', '-colorspace', 'bt709',
385
+ '-color_primaries', 'bt709', '-color_trc', 'bt709',
386
+ '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
387
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
388
+ '-pix_fmt', 'yuv420p',
389
+ '-t', finalDur.toFixed(3),
390
+ '-movflags', '+faststart',
391
+ outPath
392
+ );
393
+
394
+ const result = spawnSync('ffmpeg', ffmpegArgs, { timeout: 5 * 60 * 1000, encoding: 'utf8' });
395
+ if (result.status !== 0) {
396
+ throw new Error(`editClip failed: ${(result.stderr || '').slice(-400)}`);
397
+ }
398
+
399
+ return {
400
+ outputPath: outPath,
401
+ duration: finalDur,
402
+ width: targetW,
403
+ height: targetH,
404
+ hasAudio: true,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Concat multiple clips with xfade transitions and acrossfade audio.
410
+ *
411
+ * @param {{
412
+ * clips: Array<string|{ path: string, duration?: number, transition?: string, transitionDuration?: number }>,
413
+ * transition?: string,
414
+ * transitionDuration?: number,
415
+ * outputPath?: string,
416
+ * }} opts
417
+ * @returns {{ outputPath: string, duration: number, clipCount: number }}
418
+ */
419
+ concatClipsWithTransitions({
420
+ clips = [],
421
+ transition = 'fade',
422
+ transitionDuration = 0.5,
423
+ outputPath = null,
424
+ } = {}) {
425
+ if (!Array.isArray(clips) || clips.length === 0) {
426
+ throw new Error('concatClipsWithTransitions requires at least one clip');
427
+ }
428
+
429
+ fs.mkdirSync(this.editDir, { recursive: true });
430
+
431
+ // Normalize clip entries
432
+ const normClips = clips.map((c, i) => {
433
+ const p = typeof c === 'string' ? c : c.path;
434
+ if (!p || !fs.existsSync(p)) {
435
+ throw new Error(`Clip ${i} not found: ${p}`);
436
+ }
437
+ const dur = (typeof c === 'object' && c.duration) ? c.duration : this.probeMedia(p).duration;
438
+ const trans = (typeof c === 'object' && c.transition) ? c.transition : transition;
439
+ const transDur = (typeof c === 'object' && c.transitionDuration != null) ? c.transitionDuration : transitionDuration;
440
+ return { path: p, duration: dur, transition: trans, transitionDuration: transDur };
441
+ });
442
+
443
+ const outPath = outputPath || path.join(
444
+ this.editDir,
445
+ `concat_${crypto.randomBytes(4).toString('hex')}_${Date.now()}.mp4`
446
+ );
447
+
448
+ // Single clip edge case
449
+ if (normClips.length === 1) {
450
+ fs.copyFileSync(normClips[0].path, outPath);
451
+ return { outputPath: outPath, duration: normClips[0].duration, clipCount: 1 };
452
+ }
453
+
454
+ const N = normClips.length;
455
+
456
+ // Check if transitions are enabled
457
+ const usesTransitions = normClips.some(c => c.transition && c.transition !== 'none' && c.transitionDuration > 0);
458
+
459
+ if (!usesTransitions) {
460
+ // Fast concat filter without xfade
461
+ const ffmpegArgs = ['-nostdin', '-y'];
462
+ for (const c of normClips) {
463
+ ffmpegArgs.push('-i', c.path);
464
+ }
465
+ const streams = normClips.map((_, i) => `[${i}:v][${i}:a]`).join('');
466
+ const filter = `${streams}concat=n=${N}:v=1:a=1[vout][aout]`;
467
+ ffmpegArgs.push(
468
+ '-filter_complex', filter,
469
+ '-map', '[vout]', '-map', '[aout]',
470
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
471
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
472
+ '-pix_fmt', 'yuv420p',
473
+ '-movflags', '+faststart',
474
+ outPath
475
+ );
476
+ const res = spawnSync('ffmpeg', ffmpegArgs, { timeout: 10 * 60 * 1000, encoding: 'utf8' });
477
+ if (res.status !== 0) {
478
+ throw new Error(`concatClips failed: ${(res.stderr || '').slice(-400)}`);
479
+ }
480
+ const totalDur = normClips.reduce((sum, c) => sum + c.duration, 0);
481
+ return { outputPath: outPath, duration: totalDur, clipCount: N };
482
+ }
483
+
484
+ // Build xfade + acrossfade filter complex
485
+ const ffmpegArgs = ['-nostdin', '-y'];
486
+ for (const c of normClips) {
487
+ ffmpegArgs.push('-i', c.path);
488
+ }
489
+
490
+ const vFilters = [];
491
+ const aFilters = [];
492
+
493
+ let currentOffset = 0;
494
+
495
+ for (let i = 1; i < N; i++) {
496
+ const prevV = (i === 1) ? '[0:v]' : `[v${i - 1}]`;
497
+ const prevA = (i === 1) ? '[0:a]' : `[a${i - 1}]`;
498
+ const nextV = `[${i}:v]`;
499
+ const nextA = `[${i}:a]`;
500
+
501
+ const outV = (i === N - 1) ? '[vout]' : `[v${i}]`;
502
+ const outA = (i === N - 1) ? '[aout]' : `[a${i}]`;
503
+
504
+ const clipConfig = normClips[i - 1];
505
+ let tType = clipConfig.transition || transition || 'fade';
506
+ if (!VALID_TRANSITIONS.has(tType)) tType = 'fade';
507
+
508
+ // Duration of transition
509
+ let tDur = clipConfig.transitionDuration != null ? clipConfig.transitionDuration : transitionDuration;
510
+ // Guard: transition duration cannot exceed 45% of clip duration
511
+ tDur = Math.min(tDur, normClips[i - 1].duration * 0.45, normClips[i].duration * 0.45);
512
+ tDur = Math.max(0.1, tDur);
513
+
514
+ if (i === 1) {
515
+ currentOffset = normClips[0].duration - tDur;
516
+ } else {
517
+ currentOffset = currentOffset + normClips[i - 1].duration - tDur;
518
+ }
519
+
520
+ vFilters.push(
521
+ `${prevV}${nextV}xfade=transition=${tType}:duration=${tDur.toFixed(3)}:offset=${currentOffset.toFixed(3)}${outV}`
522
+ );
523
+ aFilters.push(
524
+ `${prevA}${nextA}acrossfade=d=${tDur.toFixed(3)}:curve1=tri:curve2=tri${outA}`
525
+ );
526
+ }
527
+
528
+ const filterComplex = [...vFilters, ...aFilters].join(';');
529
+
530
+ ffmpegArgs.push(
531
+ '-filter_complex', filterComplex,
532
+ '-map', '[vout]', '-map', '[aout]',
533
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
534
+ '-profile:v', 'high', '-level', '4.1',
535
+ '-color_range', 'tv', '-colorspace', 'bt709',
536
+ '-color_primaries', 'bt709', '-color_trc', 'bt709',
537
+ '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
538
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
539
+ '-pix_fmt', 'yuv420p',
540
+ '-movflags', '+faststart',
541
+ outPath
542
+ );
543
+
544
+ const res = spawnSync('ffmpeg', ffmpegArgs, { timeout: 10 * 60 * 1000, encoding: 'utf8' });
545
+ if (res.status !== 0) {
546
+ throw new Error(`concatClipsWithTransitions failed: ${(res.stderr || '').slice(-400)}`);
547
+ }
548
+
549
+ const finalDur = currentOffset + normClips[N - 1].duration;
550
+ return { outputPath: outPath, duration: finalDur, clipCount: N };
551
+ }
552
+
553
+ /**
554
+ * End-to-end autonomous video assemble:
555
+ * Slices clips, adjusts speed/color/captions, stitches with transitions,
556
+ * mixes master narration and ducked background music.
557
+ *
558
+ * @param {{
559
+ * clips: Array<object>,
560
+ * audioPath?: string,
561
+ * musicPath?: string,
562
+ * musicVolume?: number,
563
+ * orientation?: 'landscape'|'portrait'|'square',
564
+ * width?: number,
565
+ * height?: number,
566
+ * colorGrade?: string,
567
+ * transition?: string,
568
+ * transitionDuration?: number,
569
+ * autoFit?: boolean,
570
+ * outputPath?: string,
571
+ * }} opts
572
+ * @returns {{ outputPath: string, duration: number, sizeBytes: number, clipCount: number }}
573
+ */
574
+ editAndAssemble({
575
+ clips = [],
576
+ audioPath = null,
577
+ musicPath = null,
578
+ musicVolume = 0.08,
579
+ orientation = 'landscape',
580
+ width,
581
+ height,
582
+ colorGrade = null,
583
+ transition = 'fade',
584
+ transitionDuration = 0.5,
585
+ autoFit = false,
586
+ outputPath = null,
587
+ } = {}) {
588
+ if (!Array.isArray(clips) || clips.length === 0) {
589
+ throw new Error('editAndAssemble requires at least one clip in clips array');
590
+ }
591
+
592
+ fs.mkdirSync(this.editDir, { recursive: true });
593
+
594
+ // Target dimensions
595
+ let targetW = width;
596
+ let targetH = height;
597
+ if (!targetW || !targetH) {
598
+ if (orientation === 'portrait') {
599
+ targetW = 1080;
600
+ targetH = 1920;
601
+ } else if (orientation === 'square') {
602
+ targetW = 1080;
603
+ targetH = 1080;
604
+ } else {
605
+ targetW = 1920;
606
+ targetH = 1080;
607
+ }
608
+ }
609
+
610
+ // Auto-fit duration logic if master audio is provided
611
+ let perClipDuration = null;
612
+ let masterAudioDur = 0;
613
+ if (audioPath && fs.existsSync(audioPath) && (autoFit || clips.every(c => !c.duration && !c.end_time && !c.endTime))) {
614
+ masterAudioDur = this.probeMedia(audioPath).duration;
615
+ if (masterAudioDur > 0) {
616
+ // Total duration with xfade: D_total = N * d - (N - 1) * td = masterAudioDur
617
+ // => N * d = masterAudioDur + (N - 1) * td => d = (masterAudioDur + (N - 1) * td) / N
618
+ const td = transitionDuration || 0.5;
619
+ perClipDuration = Math.max(1.5, (masterAudioDur + (clips.length - 1) * td) / clips.length);
620
+ }
621
+ }
622
+
623
+ // Step 1: Process each clip
624
+ const editedClips = [];
625
+ for (let i = 0; i < clips.length; i++) {
626
+ const c = clips[i];
627
+ const videoP = c.video_path || c.videoPath || c.path;
628
+ if (!videoP) throw new Error(`Clip ${i} is missing video_path`);
629
+
630
+ const cDur = c.duration || perClipDuration || null;
631
+ const cSpeed = c.speed || 1.0;
632
+ const cVol = (c.clip_volume != null) ? c.clip_volume : (c.clipVolume != null ? c.clipVolume : 1.0);
633
+ const cGrade = c.color_grade || c.colorGrade || colorGrade || null;
634
+ const cCaption = c.caption || '';
635
+ const cVoice = c.audio_path || c.audioPath || null;
636
+ const cStart = c.start_time != null ? c.start_time : (c.startTime != null ? c.startTime : (c.trim_start != null ? c.trim_start : 0));
637
+ const cEnd = c.end_time != null ? c.end_time : (c.endTime != null ? c.endTime : (c.trim_end != null ? c.trim_end : null));
638
+
639
+ const edited = this.editClip({
640
+ videoPath: videoP,
641
+ startTime: cStart,
642
+ endTime: cEnd,
643
+ duration: cDur,
644
+ speed: cSpeed,
645
+ clipVolume: cVol,
646
+ voicePath: cVoice,
647
+ colorGrade: cGrade,
648
+ caption: cCaption,
649
+ orientation,
650
+ width: targetW,
651
+ height: targetH,
652
+ });
653
+
654
+ editedClips.push({
655
+ path: edited.outputPath,
656
+ duration: edited.duration,
657
+ transition: c.transition || transition,
658
+ transitionDuration: c.transition_duration || transitionDuration,
659
+ });
660
+ }
661
+
662
+ // Step 2: Join clips with transitions
663
+ const joined = this.concatClipsWithTransitions({
664
+ clips: editedClips,
665
+ transition,
666
+ transitionDuration,
667
+ });
668
+
669
+ let currentVideoPath = joined.outputPath;
670
+
671
+ // Step 3: Mix master audio track if provided
672
+ if (audioPath && fs.existsSync(audioPath)) {
673
+ const audioMuxPath = path.join(
674
+ this.editDir,
675
+ `mux_${crypto.randomBytes(4).toString('hex')}_${Date.now()}.mp4`
676
+ );
677
+
678
+ // Mix master voiceover: replace or mix over ambient audio
679
+ const result = spawnSync('ffmpeg', [
680
+ '-nostdin', '-y',
681
+ '-i', currentVideoPath,
682
+ '-i', audioPath,
683
+ '-c:v', 'copy',
684
+ '-map', '0:v:0',
685
+ '-map', '1:a:0',
686
+ '-shortest',
687
+ '-movflags', '+faststart',
688
+ audioMuxPath,
689
+ ], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
690
+
691
+ if (result.status === 0) {
692
+ currentVideoPath = audioMuxPath;
693
+ }
694
+ }
695
+
696
+ // Step 4: Mix background music with ducking if provided
697
+ const outFinal = outputPath || path.join(
698
+ this.filesDir,
699
+ `video_edit_${crypto.randomBytes(4).toString('hex')}_${Date.now()}.mp4`
700
+ );
701
+
702
+ if (musicPath && fs.existsSync(musicPath)) {
703
+ this.mixer.mixVideo({
704
+ videoPath: currentVideoPath,
705
+ musicPath,
706
+ outputPath: outFinal,
707
+ });
708
+ } else {
709
+ fs.copyFileSync(currentVideoPath, outFinal);
710
+ }
711
+
712
+ const finalProbe = this.probeMedia(outFinal);
713
+
714
+ return {
715
+ outputPath: outFinal,
716
+ duration: finalProbe.duration,
717
+ sizeBytes: finalProbe.sizeBytes,
718
+ width: finalProbe.width,
719
+ height: finalProbe.height,
720
+ clipCount: clips.length,
721
+ };
722
+ }
723
+
724
+ /**
725
+ * Dispatch imperative editing operations (for external_platform_call fallback).
726
+ * Supports: cut, trim, speed, color, caption, mute, transition, assemble.
727
+ */
728
+ async applyOperations({
729
+ operations = [],
730
+ files = [],
731
+ outputPath = null,
732
+ orientation = 'landscape',
733
+ musicPath = null,
734
+ musicVolume = 0.08,
735
+ audioPath = null,
736
+ } = {}) {
737
+ if (!Array.isArray(operations) || operations.length === 0) {
738
+ throw new Error('applyOperations requires non-empty operations array');
739
+ }
740
+
741
+ // Group operations into clip specs
742
+ const clipSpecs = [];
743
+ let globalTransition = 'fade';
744
+ let globalTransDur = 0.5;
745
+
746
+ // Resolve initial clips from files or operations
747
+ for (let i = 0; i < files.length; i++) {
748
+ clipSpecs.push({ video_path: files[i], clip_volume: 1.0 });
749
+ }
750
+
751
+ for (const op of operations) {
752
+ const targetIndex = (typeof op.clip === 'number') ? op.clip : 0;
753
+ let targetClip = clipSpecs[targetIndex];
754
+
755
+ if (!targetClip && op.clip && typeof op.clip === 'string') {
756
+ targetClip = clipSpecs.find(c => c.video_path === op.clip);
757
+ if (!targetClip) {
758
+ targetClip = { video_path: op.clip, clip_volume: 1.0 };
759
+ clipSpecs.push(targetClip);
760
+ }
761
+ }
762
+
763
+ switch (op.op) {
764
+ case 'cut':
765
+ case 'trim':
766
+ if (targetClip) {
767
+ if (op.at != null || op.start != null) targetClip.start_time = op.at || op.start;
768
+ if (op.to != null || op.end != null) targetClip.end_time = op.to || op.end;
769
+ if (op.duration != null) targetClip.duration = op.duration;
770
+ }
771
+ break;
772
+
773
+ case 'speed':
774
+ if (targetClip) {
775
+ targetClip.speed = op.speed || 1.0;
776
+ }
777
+ break;
778
+
779
+ case 'color':
780
+ if (targetClip) {
781
+ targetClip.color_grade = op.grade || op.type || op.color;
782
+ }
783
+ break;
784
+
785
+ case 'caption':
786
+ if (targetClip) {
787
+ targetClip.caption = op.text || op.caption || '';
788
+ }
789
+ break;
790
+
791
+ case 'mute':
792
+ if (targetClip) {
793
+ targetClip.clip_volume = 0;
794
+ }
795
+ break;
796
+
797
+ case 'transition':
798
+ globalTransition = op.type || op.transition || 'fade';
799
+ if (op.duration != null) globalTransDur = parseTimestamp(op.duration);
800
+ if (targetClip) {
801
+ targetClip.transition = globalTransition;
802
+ targetClip.transition_duration = globalTransDur;
803
+ }
804
+ break;
805
+
806
+ case 'assemble':
807
+ // Explicit assemble op can override clips/audio/music
808
+ if (Array.isArray(op.clips)) {
809
+ clipSpecs.length = 0;
810
+ clipSpecs.push(...op.clips);
811
+ }
812
+ if (op.audio) audioPath = op.audio;
813
+ if (op.music) musicPath = op.music;
814
+ break;
815
+
816
+ default:
817
+ break;
818
+ }
819
+ }
820
+
821
+ if (clipSpecs.length === 0) {
822
+ throw new Error('No valid clips defined in files or operations');
823
+ }
824
+
825
+ const result = this.editAndAssemble({
826
+ clips: clipSpecs,
827
+ audioPath,
828
+ musicPath,
829
+ musicVolume,
830
+ orientation,
831
+ transition: globalTransition,
832
+ transitionDuration: globalTransDur,
833
+ outputPath,
834
+ });
835
+
836
+ return {
837
+ status: 'success',
838
+ operationsApplied: operations.length,
839
+ ...result,
840
+ };
841
+ }
842
+ }
843
+
844
+ module.exports = {
845
+ VideoEditor,
846
+ parseTimestamp,
847
+ buildColorFilter,
848
+ buildSpeedFilters,
849
+ buildCaptionFilter,
850
+ COLOR_GRADES,
851
+ VALID_TRANSITIONS,
852
+ };