@amg555/frameforge-mcp 2.3.5 → 2.3.7
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/CHANGELOG.md +47 -0
- package/README.md +7 -5
- package/package.json +3 -3
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +70 -14
- package/scripts/deep-validate.js +5 -3
- package/src/cli/make.js +6 -0
- package/src/mcp-server/server.js +166 -14
- package/src/video-editor/editor.js +852 -0
- package/src/video-renderer/renderer.js +129 -31
- package/src/video-renderer/scene-animator.js +1033 -251
- package/src/worker/server-render-free.js +5 -0
- package/src/worker/server.js +6 -0
- package/src/workflows/assets.js +25 -3
- package/src/workflows/render.js +26 -3
- package/src/workflows/script.js +2 -0
|
@@ -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
|
+
};
|