apexify.js 5.1.0 → 5.2.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.
- package/CHANGELOG.md +263 -38
- package/README.md +248 -1109
- package/dist/cjs/Canvas/ApexPainter.d.ts +182 -204
- package/dist/cjs/Canvas/ApexPainter.d.ts.map +1 -1
- package/dist/cjs/Canvas/ApexPainter.js +482 -1286
- package/dist/cjs/Canvas/ApexPainter.js.map +1 -1
- package/dist/cjs/Canvas/extended/CanvasCreator.d.ts +33 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.js +223 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/ChartCreator.d.ts +26 -0
- package/dist/cjs/Canvas/extended/ChartCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/ChartCreator.js +50 -0
- package/dist/cjs/Canvas/extended/ChartCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/GIFCreator.d.ts +43 -0
- package/dist/cjs/Canvas/extended/GIFCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/GIFCreator.js +157 -0
- package/dist/cjs/Canvas/extended/GIFCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/ImageCreator.d.ts +83 -0
- package/dist/cjs/Canvas/extended/ImageCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/ImageCreator.js +479 -0
- package/dist/cjs/Canvas/extended/ImageCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/TextCreator.d.ts +35 -0
- package/dist/cjs/Canvas/extended/TextCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/TextCreator.js +98 -0
- package/dist/cjs/Canvas/extended/TextCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/VideoCreator.d.ts +370 -0
- package/dist/cjs/Canvas/extended/VideoCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/VideoCreator.js +478 -0
- package/dist/cjs/Canvas/extended/VideoCreator.js.map +1 -0
- package/dist/cjs/Canvas/utils/Background/bg.d.ts +1 -1
- package/dist/cjs/Canvas/utils/Background/bg.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Background/bg.js +43 -7
- package/dist/cjs/Canvas/utils/Background/bg.js.map +1 -1
- package/dist/cjs/Canvas/utils/Charts/barchart.d.ts +230 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.js +1891 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.d.ts +103 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.js +368 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.d.ts +178 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.js +1389 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/index.d.ts +45 -0
- package/dist/cjs/Canvas/utils/Charts/index.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/index.js +17 -0
- package/dist/cjs/Canvas/utils/Charts/index.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.d.ts +216 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.js +1761 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.d.ts +167 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.js +794 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.js.map +1 -0
- package/dist/cjs/Canvas/utils/General/batchOperations.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/batchOperations.js +3 -4
- package/dist/cjs/Canvas/utils/General/batchOperations.js.map +1 -1
- package/dist/cjs/Canvas/utils/General/general functions.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/general functions.js +62 -33
- package/dist/cjs/Canvas/utils/General/general functions.js.map +1 -1
- package/dist/cjs/Canvas/utils/General/imageStitching.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/imageStitching.js +3 -6
- package/dist/cjs/Canvas/utils/General/imageStitching.js.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageMasking.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageMasking.js +5 -12
- package/dist/cjs/Canvas/utils/Image/imageMasking.js.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageProperties.d.ts +4 -4
- package/dist/cjs/Canvas/utils/Image/imageProperties.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageProperties.js +44 -9
- package/dist/cjs/Canvas/utils/Image/imageProperties.js.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.d.ts +5 -0
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.js +48 -5
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.js.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.d.ts +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.js +48 -5
- package/dist/cjs/Canvas/utils/Texts/textProperties.js.map +1 -1
- package/dist/cjs/Canvas/utils/Video/videoHelpers.d.ts +489 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.js +1835 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.js.map +1 -0
- package/dist/cjs/Canvas/utils/errorUtils.d.ts +15 -0
- package/dist/cjs/Canvas/utils/errorUtils.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/errorUtils.js +26 -0
- package/dist/cjs/Canvas/utils/errorUtils.js.map +1 -0
- package/dist/cjs/Canvas/utils/types.d.ts +17 -178
- package/dist/cjs/Canvas/utils/types.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/types.js.map +1 -1
- package/dist/cjs/Canvas/utils/utils.d.ts +4 -3
- package/dist/cjs/Canvas/utils/utils.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/utils.js +40 -6
- package/dist/cjs/Canvas/utils/utils.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +14 -45
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/Canvas/ApexPainter.d.ts +182 -204
- package/dist/esm/Canvas/ApexPainter.d.ts.map +1 -1
- package/dist/esm/Canvas/ApexPainter.js +482 -1286
- package/dist/esm/Canvas/ApexPainter.js.map +1 -1
- package/dist/esm/Canvas/extended/CanvasCreator.d.ts +33 -0
- package/dist/esm/Canvas/extended/CanvasCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/CanvasCreator.js +223 -0
- package/dist/esm/Canvas/extended/CanvasCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/ChartCreator.d.ts +26 -0
- package/dist/esm/Canvas/extended/ChartCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/ChartCreator.js +50 -0
- package/dist/esm/Canvas/extended/ChartCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/GIFCreator.d.ts +43 -0
- package/dist/esm/Canvas/extended/GIFCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/GIFCreator.js +157 -0
- package/dist/esm/Canvas/extended/GIFCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/ImageCreator.d.ts +83 -0
- package/dist/esm/Canvas/extended/ImageCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/ImageCreator.js +479 -0
- package/dist/esm/Canvas/extended/ImageCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/TextCreator.d.ts +35 -0
- package/dist/esm/Canvas/extended/TextCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/TextCreator.js +98 -0
- package/dist/esm/Canvas/extended/TextCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/VideoCreator.d.ts +370 -0
- package/dist/esm/Canvas/extended/VideoCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/VideoCreator.js +478 -0
- package/dist/esm/Canvas/extended/VideoCreator.js.map +1 -0
- package/dist/esm/Canvas/utils/Background/bg.d.ts +1 -1
- package/dist/esm/Canvas/utils/Background/bg.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Background/bg.js +43 -7
- package/dist/esm/Canvas/utils/Background/bg.js.map +1 -1
- package/dist/esm/Canvas/utils/Charts/barchart.d.ts +230 -0
- package/dist/esm/Canvas/utils/Charts/barchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/barchart.js +1891 -0
- package/dist/esm/Canvas/utils/Charts/barchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.d.ts +103 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.js +368 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.d.ts +178 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.js +1389 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/index.d.ts +45 -0
- package/dist/esm/Canvas/utils/Charts/index.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/index.js +17 -0
- package/dist/esm/Canvas/utils/Charts/index.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/linechart.d.ts +216 -0
- package/dist/esm/Canvas/utils/Charts/linechart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/linechart.js +1761 -0
- package/dist/esm/Canvas/utils/Charts/linechart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/piechart.d.ts +167 -0
- package/dist/esm/Canvas/utils/Charts/piechart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/piechart.js +794 -0
- package/dist/esm/Canvas/utils/Charts/piechart.js.map +1 -0
- package/dist/esm/Canvas/utils/General/batchOperations.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/batchOperations.js +3 -4
- package/dist/esm/Canvas/utils/General/batchOperations.js.map +1 -1
- package/dist/esm/Canvas/utils/General/general functions.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/general functions.js +62 -33
- package/dist/esm/Canvas/utils/General/general functions.js.map +1 -1
- package/dist/esm/Canvas/utils/General/imageStitching.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/imageStitching.js +3 -6
- package/dist/esm/Canvas/utils/General/imageStitching.js.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageMasking.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageMasking.js +5 -12
- package/dist/esm/Canvas/utils/Image/imageMasking.js.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageProperties.d.ts +4 -4
- package/dist/esm/Canvas/utils/Image/imageProperties.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageProperties.js +44 -9
- package/dist/esm/Canvas/utils/Image/imageProperties.js.map +1 -1
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.d.ts +5 -0
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.js +48 -5
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.js.map +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.d.ts +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.js +48 -5
- package/dist/esm/Canvas/utils/Texts/textProperties.js.map +1 -1
- package/dist/esm/Canvas/utils/Video/videoHelpers.d.ts +489 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.js +1835 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.js.map +1 -0
- package/dist/esm/Canvas/utils/errorUtils.d.ts +15 -0
- package/dist/esm/Canvas/utils/errorUtils.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/errorUtils.js +26 -0
- package/dist/esm/Canvas/utils/errorUtils.js.map +1 -0
- package/dist/esm/Canvas/utils/types.d.ts +17 -178
- package/dist/esm/Canvas/utils/types.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/types.js.map +1 -1
- package/dist/esm/Canvas/utils/utils.d.ts +4 -3
- package/dist/esm/Canvas/utils/utils.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/utils.js +40 -6
- package/dist/esm/Canvas/utils/utils.js.map +1 -1
- package/dist/esm/index.d.ts +1 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +14 -45
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +118 -82
- package/dist/cjs/Canvas/utils/Charts/charts.d.ts +0 -13
- package/dist/cjs/Canvas/utils/Charts/charts.d.ts.map +0 -1
- package/dist/cjs/Canvas/utils/Charts/charts.js +0 -466
- package/dist/cjs/Canvas/utils/Charts/charts.js.map +0 -1
- package/dist/esm/Canvas/utils/Charts/charts.d.ts +0 -13
- package/dist/esm/Canvas/utils/Charts/charts.d.ts.map +0 -1
- package/dist/esm/Canvas/utils/Charts/charts.js +0 -466
- package/dist/esm/Canvas/utils/Charts/charts.js.map +0 -1
- package/lib/Canvas/ApexPainter.ts +0 -5414
- package/lib/Canvas/utils/Background/bg.ts +0 -285
- package/lib/Canvas/utils/Charts/charts.ts +0 -548
- package/lib/Canvas/utils/Custom/advancedLines.ts +0 -387
- package/lib/Canvas/utils/Custom/customLines.ts +0 -206
- package/lib/Canvas/utils/General/batchOperations.ts +0 -103
- package/lib/Canvas/utils/General/conversion.ts +0 -34
- package/lib/Canvas/utils/General/general functions.ts +0 -726
- package/lib/Canvas/utils/General/imageCompression.ts +0 -316
- package/lib/Canvas/utils/General/imageStitching.ts +0 -252
- package/lib/Canvas/utils/Image/imageEffects.ts +0 -175
- package/lib/Canvas/utils/Image/imageFilters.ts +0 -356
- package/lib/Canvas/utils/Image/imageMasking.ts +0 -335
- package/lib/Canvas/utils/Image/imageProperties.ts +0 -587
- package/lib/Canvas/utils/Image/professionalImageFilters.ts +0 -391
- package/lib/Canvas/utils/Image/simpleProfessionalFilters.ts +0 -229
- package/lib/Canvas/utils/Patterns/enhancedPatternRenderer.ts +0 -455
- package/lib/Canvas/utils/Shapes/shapes.ts +0 -528
- package/lib/Canvas/utils/Texts/enhancedTextRenderer.ts +0 -716
- package/lib/Canvas/utils/Texts/textPathRenderer.ts +0 -320
- package/lib/Canvas/utils/Texts/textProperties.ts +0 -231
- package/lib/Canvas/utils/types.ts +0 -983
- package/lib/Canvas/utils/utils.ts +0 -135
- package/lib/index.ts +0 -81
- package/lib/utils.ts +0 -5
|
@@ -0,0 +1,1835 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Video helper functions for ApexPainter
|
|
4
|
+
* This file contains all video processing helper methods that were previously in ApexPainter.ts
|
|
5
|
+
* to keep the main class cleaner and more maintainable.
|
|
6
|
+
*/
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.VideoHelpers = void 0;
|
|
12
|
+
const canvas_1 = require("@napi-rs/canvas");
|
|
13
|
+
const child_process_1 = require("child_process");
|
|
14
|
+
const util_1 = require("util");
|
|
15
|
+
const axios_1 = __importDefault(require("axios"));
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const errorUtils_1 = require("../errorUtils");
|
|
19
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
20
|
+
/**
|
|
21
|
+
* Helper function to resolve video source (Buffer, URL, or local path) to a file path
|
|
22
|
+
* Downloads URLs and writes Buffers to temp files as needed
|
|
23
|
+
*/
|
|
24
|
+
async function resolveVideoSource(videoSource, frameDir, timestamp) {
|
|
25
|
+
if (Buffer.isBuffer(videoSource)) {
|
|
26
|
+
// Buffer: write to temp file
|
|
27
|
+
const videoPath = path_1.default.join(frameDir, `temp-video-${timestamp}.mp4`);
|
|
28
|
+
fs_1.default.writeFileSync(videoPath, videoSource);
|
|
29
|
+
return { videoPath, shouldCleanup: true };
|
|
30
|
+
}
|
|
31
|
+
else if (typeof videoSource === 'string' && /^https?:\/\//i.test(videoSource)) {
|
|
32
|
+
// HTTP/HTTPS URL: download and write to temp file
|
|
33
|
+
const response = await (0, axios_1.default)({
|
|
34
|
+
method: 'get',
|
|
35
|
+
url: videoSource,
|
|
36
|
+
responseType: 'arraybuffer'
|
|
37
|
+
});
|
|
38
|
+
const videoPath = path_1.default.join(frameDir, `temp-video-${timestamp}.mp4`);
|
|
39
|
+
fs_1.default.writeFileSync(videoPath, Buffer.from(response.data));
|
|
40
|
+
return { videoPath, shouldCleanup: true };
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Local file path: resolve and validate
|
|
44
|
+
let resolvedPath = videoSource;
|
|
45
|
+
if (!path_1.default.isAbsolute(resolvedPath)) {
|
|
46
|
+
resolvedPath = path_1.default.join(process.cwd(), resolvedPath);
|
|
47
|
+
}
|
|
48
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
49
|
+
throw new Error(`Video file not found: ${videoSource}`);
|
|
50
|
+
}
|
|
51
|
+
return { videoPath: resolvedPath, shouldCleanup: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Video helper functions class
|
|
56
|
+
* All video processing helper methods are contained here
|
|
57
|
+
*/
|
|
58
|
+
class VideoHelpers {
|
|
59
|
+
deps;
|
|
60
|
+
constructor(dependencies) {
|
|
61
|
+
this.deps = dependencies;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Helper to execute FFmpeg with progress tracking
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
async executeFFmpegWithProgress(command, options, onProgress) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const childProcess = (0, child_process_1.exec)(command, options, (error) => {
|
|
70
|
+
if (error)
|
|
71
|
+
reject(error);
|
|
72
|
+
else
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
if (onProgress) {
|
|
76
|
+
let stderrBuffer = '';
|
|
77
|
+
childProcess.stderr?.on('data', (data) => {
|
|
78
|
+
stderrBuffer += data.toString();
|
|
79
|
+
// Parse FFmpeg progress output
|
|
80
|
+
const timeMatch = stderrBuffer.match(/time=(\d+):(\d+):(\d+\.\d+)/);
|
|
81
|
+
const durationMatch = stderrBuffer.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
|
82
|
+
if (timeMatch && durationMatch) {
|
|
83
|
+
const currentTime = parseFloat(timeMatch[1]) * 3600 + parseFloat(timeMatch[2]) * 60 + parseFloat(timeMatch[3]);
|
|
84
|
+
const totalDuration = parseFloat(durationMatch[1]) * 3600 + parseFloat(durationMatch[2]) * 60 + parseFloat(durationMatch[3]);
|
|
85
|
+
const percent = Math.min(100, (currentTime / totalDuration) * 100);
|
|
86
|
+
const speedMatch = stderrBuffer.match(/speed=\s*([\d.]+)x/);
|
|
87
|
+
const speed = speedMatch ? parseFloat(speedMatch[1]) : 1;
|
|
88
|
+
onProgress({ percent, time: currentTime, speed });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Generate video thumbnail (grid of frames)
|
|
96
|
+
*/
|
|
97
|
+
async generateVideoThumbnail(videoSource, options, videoInfo) {
|
|
98
|
+
const count = options.count || 9;
|
|
99
|
+
const grid = options.grid || { cols: 3, rows: 3 };
|
|
100
|
+
const frameWidth = options.width || 320;
|
|
101
|
+
const frameHeight = options.height || 180;
|
|
102
|
+
const outputFormat = options.outputFormat || 'jpg';
|
|
103
|
+
const quality = options.quality || 2;
|
|
104
|
+
if (!videoInfo) {
|
|
105
|
+
videoInfo = await this.deps.getVideoInfo(videoSource, true);
|
|
106
|
+
}
|
|
107
|
+
const duration = videoInfo.duration;
|
|
108
|
+
const interval = duration / (count + 1); // Distribute frames evenly
|
|
109
|
+
// Extract frames
|
|
110
|
+
const frames = [];
|
|
111
|
+
for (let i = 1; i <= count; i++) {
|
|
112
|
+
const time = interval * i;
|
|
113
|
+
const frame = await this.deps.extractVideoFrame(videoSource, 0, time, outputFormat, quality);
|
|
114
|
+
if (frame) {
|
|
115
|
+
frames.push(frame);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Create thumbnail canvas
|
|
119
|
+
const thumbnailWidth = frameWidth * grid.cols;
|
|
120
|
+
const thumbnailHeight = frameHeight * grid.rows;
|
|
121
|
+
const canvas = (0, canvas_1.createCanvas)(thumbnailWidth, thumbnailHeight);
|
|
122
|
+
const ctx = (0, errorUtils_1.getCanvasContext)(canvas);
|
|
123
|
+
// Draw frames in grid
|
|
124
|
+
for (let i = 0; i < frames.length; i++) {
|
|
125
|
+
const row = Math.floor(i / grid.cols);
|
|
126
|
+
const col = i % grid.cols;
|
|
127
|
+
const x = col * frameWidth;
|
|
128
|
+
const y = row * frameHeight;
|
|
129
|
+
const frameImage = await (0, canvas_1.loadImage)(frames[i]);
|
|
130
|
+
ctx.drawImage(frameImage, x, y, frameWidth, frameHeight);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
buffer: canvas.toBuffer('image/png'),
|
|
134
|
+
canvas: { width: thumbnailWidth, height: thumbnailHeight }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Convert video format
|
|
139
|
+
*/
|
|
140
|
+
async convertVideo(videoSource, options) {
|
|
141
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
142
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
143
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
const timestamp = Date.now();
|
|
146
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
147
|
+
const format = options.format || 'mp4';
|
|
148
|
+
const qualityPresets = {
|
|
149
|
+
low: '-crf 28',
|
|
150
|
+
medium: '-crf 23',
|
|
151
|
+
high: '-crf 18',
|
|
152
|
+
ultra: '-crf 15'
|
|
153
|
+
};
|
|
154
|
+
const qualityFlag = options.bitrate
|
|
155
|
+
? `-b:v ${options.bitrate}k`
|
|
156
|
+
: qualityPresets[options.quality || 'medium'];
|
|
157
|
+
const fpsFlag = options.fps ? `-r ${options.fps}` : '';
|
|
158
|
+
const resolutionFlag = options.resolution
|
|
159
|
+
? `-vf scale=${options.resolution.width}:${options.resolution.height}`
|
|
160
|
+
: '';
|
|
161
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
162
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
163
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${qualityFlag} ${fpsFlag} ${resolutionFlag} -y "${escapedOutputPath}"`;
|
|
164
|
+
try {
|
|
165
|
+
await execAsync(command, {
|
|
166
|
+
timeout: 300000, // 5 minute timeout
|
|
167
|
+
maxBuffer: 10 * 1024 * 1024
|
|
168
|
+
});
|
|
169
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
170
|
+
fs_1.default.unlinkSync(videoPath);
|
|
171
|
+
}
|
|
172
|
+
return { outputPath: options.outputPath, success: true };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
176
|
+
fs_1.default.unlinkSync(videoPath);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Trim/Cut video
|
|
183
|
+
*/
|
|
184
|
+
async trimVideo(videoSource, options) {
|
|
185
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
186
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
187
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
188
|
+
}
|
|
189
|
+
const timestamp = Date.now();
|
|
190
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
191
|
+
const duration = options.endTime - options.startTime;
|
|
192
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
193
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
194
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -ss ${options.startTime} -t ${duration} -c copy -y "${escapedOutputPath}"`;
|
|
195
|
+
try {
|
|
196
|
+
await execAsync(command, {
|
|
197
|
+
timeout: 300000,
|
|
198
|
+
maxBuffer: 10 * 1024 * 1024
|
|
199
|
+
});
|
|
200
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
201
|
+
fs_1.default.unlinkSync(videoPath);
|
|
202
|
+
}
|
|
203
|
+
return { outputPath: options.outputPath, success: true };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
207
|
+
fs_1.default.unlinkSync(videoPath);
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Extract audio from video
|
|
214
|
+
*/
|
|
215
|
+
async extractAudio(videoSource, options) {
|
|
216
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
217
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
218
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
const timestamp = Date.now();
|
|
221
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
222
|
+
// Check if video has audio stream
|
|
223
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
224
|
+
try {
|
|
225
|
+
const { stdout } = await execAsync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_type -of default=noprint_wrappers=1:nokey=1 "${escapedVideoPath}"`, { timeout: 10000, maxBuffer: 1024 * 1024 });
|
|
226
|
+
const hasAudio = stdout.toString().trim() === 'audio';
|
|
227
|
+
if (!hasAudio) {
|
|
228
|
+
throw new Error('Video does not contain an audio stream. Cannot extract audio.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error instanceof Error && error.message.includes('Video does not contain')) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
// If ffprobe fails, assume no audio
|
|
236
|
+
throw new Error('Video does not contain an audio stream. Cannot extract audio.');
|
|
237
|
+
}
|
|
238
|
+
const format = options.format || 'mp3';
|
|
239
|
+
const bitrate = options.bitrate || 128;
|
|
240
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
241
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vn -acodec ${format === 'mp3' ? 'libmp3lame' : format === 'wav' ? 'pcm_s16le' : format === 'aac' ? 'aac' : 'libvorbis'} -ab ${bitrate}k -y "${escapedOutputPath}"`;
|
|
242
|
+
try {
|
|
243
|
+
await execAsync(command, {
|
|
244
|
+
timeout: 300000,
|
|
245
|
+
maxBuffer: 10 * 1024 * 1024
|
|
246
|
+
});
|
|
247
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
248
|
+
fs_1.default.unlinkSync(videoPath);
|
|
249
|
+
}
|
|
250
|
+
return { outputPath: options.outputPath, success: true };
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
254
|
+
fs_1.default.unlinkSync(videoPath);
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Add watermark to video
|
|
261
|
+
*/
|
|
262
|
+
async addWatermarkToVideo(videoSource, options) {
|
|
263
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
264
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
265
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
266
|
+
}
|
|
267
|
+
const timestamp = Date.now();
|
|
268
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
269
|
+
let watermarkPath = options.watermarkPath;
|
|
270
|
+
if (!/^https?:\/\//i.test(watermarkPath)) {
|
|
271
|
+
watermarkPath = path_1.default.join(process.cwd(), watermarkPath);
|
|
272
|
+
}
|
|
273
|
+
if (!fs_1.default.existsSync(watermarkPath)) {
|
|
274
|
+
throw new Error(`Watermark file not found: ${options.watermarkPath}`);
|
|
275
|
+
}
|
|
276
|
+
const position = options.position || 'bottom-right';
|
|
277
|
+
const opacity = options.opacity || 0.5;
|
|
278
|
+
const size = options.size ? `scale=${options.size.width}:${options.size.height}` : '';
|
|
279
|
+
const positionMap = {
|
|
280
|
+
'top-left': '10:10',
|
|
281
|
+
'top-right': 'W-w-10:10',
|
|
282
|
+
'bottom-left': '10:H-h-10',
|
|
283
|
+
'bottom-right': 'W-w-10:H-h-10',
|
|
284
|
+
'center': '(W-w)/2:(H-h)/2'
|
|
285
|
+
};
|
|
286
|
+
const overlay = positionMap[position];
|
|
287
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
288
|
+
const escapedWatermarkPath = watermarkPath.replace(/"/g, '\\"');
|
|
289
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
290
|
+
const filter = `[1:v]${size ? size + ',' : ''}format=rgba,colorchannelmixer=aa=${opacity}[wm];[0:v][wm]overlay=${overlay}`;
|
|
291
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -i "${escapedWatermarkPath}" -filter_complex "${filter}" -y "${escapedOutputPath}"`;
|
|
292
|
+
try {
|
|
293
|
+
await execAsync(command, {
|
|
294
|
+
timeout: 300000,
|
|
295
|
+
maxBuffer: 10 * 1024 * 1024
|
|
296
|
+
});
|
|
297
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
298
|
+
fs_1.default.unlinkSync(videoPath);
|
|
299
|
+
}
|
|
300
|
+
return { outputPath: options.outputPath, success: true };
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
304
|
+
fs_1.default.unlinkSync(videoPath);
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Change video speed
|
|
311
|
+
*/
|
|
312
|
+
async changeVideoSpeed(videoSource, options) {
|
|
313
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
314
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
315
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
316
|
+
}
|
|
317
|
+
const timestamp = Date.now();
|
|
318
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
319
|
+
// Check if video has audio stream
|
|
320
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
321
|
+
let hasAudio = false;
|
|
322
|
+
try {
|
|
323
|
+
const { stdout } = await execAsync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_type -of default=noprint_wrappers=1:nokey=1 "${escapedVideoPath}"`, { timeout: 10000, maxBuffer: 1024 * 1024 });
|
|
324
|
+
hasAudio = stdout.toString().trim() === 'audio';
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
hasAudio = false;
|
|
328
|
+
}
|
|
329
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
330
|
+
let command;
|
|
331
|
+
if (hasAudio) {
|
|
332
|
+
// Video has audio - process both video and audio
|
|
333
|
+
// For speeds > 2.0, we need to chain atempo filters (atempo max is 2.0)
|
|
334
|
+
if (options.speed > 2.0) {
|
|
335
|
+
const atempoCount = Math.ceil(Math.log2(options.speed));
|
|
336
|
+
const atempoValue = Math.pow(2, Math.log2(options.speed) / atempoCount);
|
|
337
|
+
const atempoFilters = Array(atempoCount).fill(atempoValue).map(v => `atempo=${v}`).join(',');
|
|
338
|
+
command = `ffmpeg -i "${escapedVideoPath}" -filter_complex "[0:v]setpts=${1 / options.speed}*PTS[v];[0:a]${atempoFilters}[a]" -map "[v]" -map "[a]" -y "${escapedOutputPath}"`;
|
|
339
|
+
}
|
|
340
|
+
else if (options.speed < 0.5) {
|
|
341
|
+
// For speeds < 0.5, we need to chain atempo filters
|
|
342
|
+
const atempoCount = Math.ceil(Math.log2(1 / options.speed));
|
|
343
|
+
const atempoValue = Math.pow(0.5, Math.log2(1 / options.speed) / atempoCount);
|
|
344
|
+
const atempoFilters = Array(atempoCount).fill(atempoValue).map(v => `atempo=${v}`).join(',');
|
|
345
|
+
command = `ffmpeg -i "${escapedVideoPath}" -filter_complex "[0:v]setpts=${1 / options.speed}*PTS[v];[0:a]${atempoFilters}[a]" -map "[v]" -map "[a]" -y "${escapedOutputPath}"`;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
// Normal speed range (0.5 to 2.0)
|
|
349
|
+
command = `ffmpeg -i "${escapedVideoPath}" -filter_complex "[0:v]setpts=${1 / options.speed}*PTS[v];[0:a]atempo=${options.speed}[a]" -map "[v]" -map "[a]" -y "${escapedOutputPath}"`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// No audio - only process video
|
|
354
|
+
command = `ffmpeg -i "${escapedVideoPath}" -filter_complex "[0:v]setpts=${1 / options.speed}*PTS[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
await execAsync(command, {
|
|
358
|
+
timeout: 300000,
|
|
359
|
+
maxBuffer: 10 * 1024 * 1024
|
|
360
|
+
});
|
|
361
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
362
|
+
fs_1.default.unlinkSync(videoPath);
|
|
363
|
+
}
|
|
364
|
+
return { outputPath: options.outputPath, success: true };
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
368
|
+
fs_1.default.unlinkSync(videoPath);
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Generate video preview (multiple frames)
|
|
375
|
+
*/
|
|
376
|
+
async generateVideoPreview(videoSource, options, videoInfo) {
|
|
377
|
+
const count = options.count || 10;
|
|
378
|
+
const outputDir = options.outputDirectory || path_1.default.join(process.cwd(), 'video-preview');
|
|
379
|
+
const outputFormat = options.outputFormat || 'png';
|
|
380
|
+
const quality = options.quality || 2;
|
|
381
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
382
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
383
|
+
}
|
|
384
|
+
if (!videoInfo) {
|
|
385
|
+
videoInfo = await this.deps.getVideoInfo(videoSource, true);
|
|
386
|
+
}
|
|
387
|
+
const duration = videoInfo.duration;
|
|
388
|
+
const interval = duration / (count + 1);
|
|
389
|
+
const frames = [];
|
|
390
|
+
for (let i = 1; i <= count; i++) {
|
|
391
|
+
const time = interval * i;
|
|
392
|
+
const frameBuffer = await this.deps.extractVideoFrame(videoSource, 0, time, outputFormat, quality);
|
|
393
|
+
if (frameBuffer) {
|
|
394
|
+
const framePath = path_1.default.join(outputDir, `preview-${String(i).padStart(3, '0')}.${outputFormat}`);
|
|
395
|
+
fs_1.default.writeFileSync(framePath, frameBuffer);
|
|
396
|
+
frames.push({
|
|
397
|
+
source: framePath,
|
|
398
|
+
frameNumber: i,
|
|
399
|
+
time: time
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return frames;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Apply video effects/filters
|
|
407
|
+
*/
|
|
408
|
+
async applyVideoEffects(videoSource, options) {
|
|
409
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
410
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
411
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
412
|
+
}
|
|
413
|
+
const timestamp = Date.now();
|
|
414
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
415
|
+
// Build filter chain
|
|
416
|
+
const filters = [];
|
|
417
|
+
for (const filter of options.filters) {
|
|
418
|
+
switch (filter.type) {
|
|
419
|
+
case 'blur':
|
|
420
|
+
filters.push(`boxblur=${filter.intensity || 5}`);
|
|
421
|
+
break;
|
|
422
|
+
case 'brightness':
|
|
423
|
+
filters.push(`eq=brightness=${((filter.value || 0) / 100).toFixed(2)}`);
|
|
424
|
+
break;
|
|
425
|
+
case 'contrast':
|
|
426
|
+
filters.push(`eq=contrast=${1 + ((filter.value || 0) / 100)}`);
|
|
427
|
+
break;
|
|
428
|
+
case 'saturation':
|
|
429
|
+
filters.push(`eq=saturation=${1 + ((filter.value || 0) / 100)}`);
|
|
430
|
+
break;
|
|
431
|
+
case 'grayscale':
|
|
432
|
+
filters.push('hue=s=0');
|
|
433
|
+
break;
|
|
434
|
+
case 'sepia':
|
|
435
|
+
filters.push('colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131');
|
|
436
|
+
break;
|
|
437
|
+
case 'invert':
|
|
438
|
+
filters.push('negate');
|
|
439
|
+
break;
|
|
440
|
+
case 'sharpen':
|
|
441
|
+
filters.push(`unsharp=5:5:${filter.intensity || 1.0}:5:5:0.0`);
|
|
442
|
+
break;
|
|
443
|
+
case 'noise':
|
|
444
|
+
filters.push(`noise=alls=${filter.intensity || 20}:allf=t+u`);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const filterChain = filters.length > 0 ? `-vf "${filters.join(',')}"` : '';
|
|
449
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
450
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
451
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${filterChain} -y "${escapedOutputPath}"`;
|
|
452
|
+
try {
|
|
453
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
454
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
455
|
+
fs_1.default.unlinkSync(videoPath);
|
|
456
|
+
}
|
|
457
|
+
return { outputPath: options.outputPath, success: true };
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
461
|
+
fs_1.default.unlinkSync(videoPath);
|
|
462
|
+
}
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Merge/Concatenate videos
|
|
468
|
+
*/
|
|
469
|
+
async mergeVideos(options) {
|
|
470
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
471
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
472
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
473
|
+
}
|
|
474
|
+
const timestamp = Date.now();
|
|
475
|
+
const videoPaths = [];
|
|
476
|
+
const shouldCleanup = [];
|
|
477
|
+
// Prepare all video files
|
|
478
|
+
for (let i = 0; i < options.videos.length; i++) {
|
|
479
|
+
const video = options.videos[i];
|
|
480
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(video, frameDir, timestamp + i);
|
|
481
|
+
videoPaths.push(videoPath);
|
|
482
|
+
shouldCleanup.push(shouldCleanupVideo);
|
|
483
|
+
}
|
|
484
|
+
const mode = options.mode || 'sequential';
|
|
485
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
486
|
+
let command;
|
|
487
|
+
if (mode === 'sequential') {
|
|
488
|
+
// Create concat file
|
|
489
|
+
const concatFile = path_1.default.join(frameDir, `concat-${timestamp}.txt`);
|
|
490
|
+
const concatContent = videoPaths.map(vp => `file '${vp.replace(/'/g, "\\'")}'`).join('\n');
|
|
491
|
+
fs_1.default.writeFileSync(concatFile, concatContent);
|
|
492
|
+
command = `ffmpeg -f concat -safe 0 -i "${concatFile.replace(/"/g, '\\"')}" -c copy -y "${escapedOutputPath}"`;
|
|
493
|
+
}
|
|
494
|
+
else if (mode === 'side-by-side') {
|
|
495
|
+
const escapedPaths = videoPaths.map(vp => vp.replace(/"/g, '\\"'));
|
|
496
|
+
command = `ffmpeg -i "${escapedPaths[0]}" -i "${escapedPaths[1] || escapedPaths[0]}" -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
497
|
+
}
|
|
498
|
+
else if (mode === 'grid') {
|
|
499
|
+
const grid = options.grid || { cols: 2, rows: 2 };
|
|
500
|
+
const escapedPaths = videoPaths.map(vp => vp.replace(/"/g, '\\"'));
|
|
501
|
+
// Simplified grid - would need more complex filter for full grid
|
|
502
|
+
command = `ffmpeg -i "${escapedPaths[0]}" -i "${escapedPaths[1] || escapedPaths[0]}" -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
throw new Error(`Unknown merge mode: ${mode}`);
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
await execAsync(command, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
509
|
+
// Cleanup
|
|
510
|
+
for (let i = 0; i < videoPaths.length; i++) {
|
|
511
|
+
if (shouldCleanup[i] && fs_1.default.existsSync(videoPaths[i])) {
|
|
512
|
+
fs_1.default.unlinkSync(videoPaths[i]);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { outputPath: options.outputPath, success: true };
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// Cleanup on error
|
|
519
|
+
for (let i = 0; i < videoPaths.length; i++) {
|
|
520
|
+
if (shouldCleanup[i] && fs_1.default.existsSync(videoPaths[i])) {
|
|
521
|
+
fs_1.default.unlinkSync(videoPaths[i]);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Replace segment in video with segment from another video
|
|
529
|
+
*/
|
|
530
|
+
async replaceVideoSegment(mainVideoSource, options) {
|
|
531
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
532
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
533
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
534
|
+
}
|
|
535
|
+
const timestamp = Date.now();
|
|
536
|
+
const tempFiles = [];
|
|
537
|
+
let shouldCleanupMain = false;
|
|
538
|
+
let shouldCleanupReplacement = false;
|
|
539
|
+
// Prepare main video
|
|
540
|
+
const { videoPath: mainVideoPath, shouldCleanup: shouldCleanupMainValue } = await resolveVideoSource(mainVideoSource, frameDir, timestamp);
|
|
541
|
+
shouldCleanupMain = shouldCleanupMainValue;
|
|
542
|
+
if (shouldCleanupMain) {
|
|
543
|
+
tempFiles.push(mainVideoPath);
|
|
544
|
+
}
|
|
545
|
+
// Validate that either replacementVideo or replacementFrames is provided
|
|
546
|
+
if (!options.replacementVideo && !options.replacementFrames) {
|
|
547
|
+
throw new Error('Either replacementVideo or replacementFrames must be provided');
|
|
548
|
+
}
|
|
549
|
+
if (options.replacementVideo && options.replacementFrames) {
|
|
550
|
+
throw new Error('Cannot specify both replacementVideo and replacementFrames');
|
|
551
|
+
}
|
|
552
|
+
// Get main video info to validate times
|
|
553
|
+
const mainVideoInfo = await this.deps.getVideoInfo(mainVideoPath, true);
|
|
554
|
+
if (!mainVideoInfo) {
|
|
555
|
+
throw new Error('Failed to get main video information');
|
|
556
|
+
}
|
|
557
|
+
if (options.targetStartTime < 0 || options.targetEndTime > mainVideoInfo.duration) {
|
|
558
|
+
throw new Error(`Target time range (${options.targetStartTime}-${options.targetEndTime}s) is outside video duration (${mainVideoInfo.duration}s)`);
|
|
559
|
+
}
|
|
560
|
+
if (options.targetStartTime >= options.targetEndTime) {
|
|
561
|
+
throw new Error('targetStartTime must be less than targetEndTime');
|
|
562
|
+
}
|
|
563
|
+
const targetDuration = options.targetEndTime - options.targetStartTime;
|
|
564
|
+
const escapedMainPath = mainVideoPath.replace(/"/g, '\\"');
|
|
565
|
+
try {
|
|
566
|
+
// Step 1: Extract part before the segment to replace
|
|
567
|
+
const part1Path = path_1.default.join(frameDir, `part1-${timestamp}.mp4`);
|
|
568
|
+
tempFiles.push(part1Path);
|
|
569
|
+
if (options.targetStartTime > 0) {
|
|
570
|
+
const escapedPart1 = part1Path.replace(/"/g, '\\"');
|
|
571
|
+
const part1Command = `ffmpeg -i "${escapedMainPath}" -t ${options.targetStartTime} -c copy -y "${escapedPart1}"`;
|
|
572
|
+
await execAsync(part1Command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
573
|
+
}
|
|
574
|
+
// Step 2: Create replacement segment (from video or frames)
|
|
575
|
+
const replacementSegmentPath = path_1.default.join(frameDir, `replacement-segment-${timestamp}.mp4`);
|
|
576
|
+
tempFiles.push(replacementSegmentPath);
|
|
577
|
+
if (options.replacementVideo) {
|
|
578
|
+
// Extract replacement segment from replacement video
|
|
579
|
+
const { videoPath: replacementVideoPath, shouldCleanup: shouldCleanupReplacementValue } = await resolveVideoSource(options.replacementVideo, frameDir, timestamp + 1000);
|
|
580
|
+
shouldCleanupReplacement = shouldCleanupReplacementValue;
|
|
581
|
+
if (shouldCleanupReplacement) {
|
|
582
|
+
tempFiles.push(replacementVideoPath);
|
|
583
|
+
}
|
|
584
|
+
const replacementStartTime = options.replacementStartTime || 0;
|
|
585
|
+
const replacementDuration = options.replacementDuration || targetDuration;
|
|
586
|
+
const escapedReplacementPath = replacementVideoPath.replace(/"/g, '\\"');
|
|
587
|
+
const escapedSegment = replacementSegmentPath.replace(/"/g, '\\"');
|
|
588
|
+
const segmentCommand = `ffmpeg -i "${escapedReplacementPath}" -ss ${replacementStartTime} -t ${replacementDuration} -c copy -y "${escapedSegment}"`;
|
|
589
|
+
await execAsync(segmentCommand, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
590
|
+
}
|
|
591
|
+
else if (options.replacementFrames) {
|
|
592
|
+
// Create video from frames - will be available after we add createVideoFromFrames
|
|
593
|
+
const replacementFps = options.replacementFps || 30;
|
|
594
|
+
await this.createVideoFromFrames({
|
|
595
|
+
frames: options.replacementFrames,
|
|
596
|
+
outputPath: replacementSegmentPath,
|
|
597
|
+
fps: replacementFps,
|
|
598
|
+
format: 'mp4',
|
|
599
|
+
quality: 'high'
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// Step 3: Extract part after the segment to replace
|
|
603
|
+
const part3Path = path_1.default.join(frameDir, `part3-${timestamp}.mp4`);
|
|
604
|
+
tempFiles.push(part3Path);
|
|
605
|
+
const remainingDuration = mainVideoInfo.duration - options.targetEndTime;
|
|
606
|
+
if (remainingDuration > 0) {
|
|
607
|
+
const escapedPart3 = part3Path.replace(/"/g, '\\"');
|
|
608
|
+
const part3Command = `ffmpeg -i "${escapedMainPath}" -ss ${options.targetEndTime} -t ${remainingDuration} -c copy -y "${escapedPart3}"`;
|
|
609
|
+
await execAsync(part3Command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
610
|
+
}
|
|
611
|
+
// Step 4: Create concat file and merge all parts
|
|
612
|
+
const concatFile = path_1.default.join(frameDir, `concat-${timestamp}.txt`);
|
|
613
|
+
tempFiles.push(concatFile);
|
|
614
|
+
const concatParts = [];
|
|
615
|
+
// Add part 1 if it exists and has content
|
|
616
|
+
if (options.targetStartTime > 0 && fs_1.default.existsSync(part1Path) && fs_1.default.statSync(part1Path).size > 0) {
|
|
617
|
+
concatParts.push(part1Path.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
618
|
+
}
|
|
619
|
+
// Add replacement segment
|
|
620
|
+
if (fs_1.default.existsSync(replacementSegmentPath) && fs_1.default.statSync(replacementSegmentPath).size > 0) {
|
|
621
|
+
concatParts.push(replacementSegmentPath.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
622
|
+
}
|
|
623
|
+
// Add part 3 if it exists and has content
|
|
624
|
+
if (remainingDuration > 0 && fs_1.default.existsSync(part3Path) && fs_1.default.statSync(part3Path).size > 0) {
|
|
625
|
+
concatParts.push(part3Path.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
626
|
+
}
|
|
627
|
+
if (concatParts.length === 0) {
|
|
628
|
+
throw new Error('No valid video segments to concatenate');
|
|
629
|
+
}
|
|
630
|
+
const concatContent = concatParts.map(p => `file '${p}'`).join('\n');
|
|
631
|
+
fs_1.default.writeFileSync(concatFile, concatContent);
|
|
632
|
+
// Step 5: Concatenate all parts
|
|
633
|
+
const escapedConcatFile = concatFile.replace(/"/g, '\\"');
|
|
634
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
635
|
+
const concatCommand = `ffmpeg -f concat -safe 0 -i "${escapedConcatFile}" -c copy -y "${escapedOutputPath}"`;
|
|
636
|
+
await execAsync(concatCommand, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
637
|
+
// Cleanup temp files
|
|
638
|
+
for (const tempFile of tempFiles) {
|
|
639
|
+
if (fs_1.default.existsSync(tempFile)) {
|
|
640
|
+
try {
|
|
641
|
+
fs_1.default.unlinkSync(tempFile);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
// Ignore cleanup errors
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return { outputPath: options.outputPath, success: true };
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
// Cleanup temp files on error
|
|
652
|
+
for (const tempFile of tempFiles) {
|
|
653
|
+
if (fs_1.default.existsSync(tempFile)) {
|
|
654
|
+
try {
|
|
655
|
+
fs_1.default.unlinkSync(tempFile);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// Ignore cleanup errors
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Rotate/Flip video
|
|
667
|
+
*/
|
|
668
|
+
async rotateVideo(videoSource, options) {
|
|
669
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
670
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
671
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
672
|
+
}
|
|
673
|
+
const timestamp = Date.now();
|
|
674
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
675
|
+
const filters = [];
|
|
676
|
+
if (options.angle) {
|
|
677
|
+
const rotationMap = {
|
|
678
|
+
90: 'transpose=1',
|
|
679
|
+
180: 'transpose=1,transpose=1',
|
|
680
|
+
270: 'transpose=2'
|
|
681
|
+
};
|
|
682
|
+
filters.push(rotationMap[options.angle]);
|
|
683
|
+
}
|
|
684
|
+
if (options.flip) {
|
|
685
|
+
if (options.flip === 'horizontal') {
|
|
686
|
+
filters.push('hflip');
|
|
687
|
+
}
|
|
688
|
+
else if (options.flip === 'vertical') {
|
|
689
|
+
filters.push('vflip');
|
|
690
|
+
}
|
|
691
|
+
else if (options.flip === 'both') {
|
|
692
|
+
filters.push('hflip', 'vflip');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const filterChain = filters.length > 0 ? `-vf "${filters.join(',')}"` : '';
|
|
696
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
697
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
698
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${filterChain} -y "${escapedOutputPath}"`;
|
|
699
|
+
try {
|
|
700
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
701
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
702
|
+
fs_1.default.unlinkSync(videoPath);
|
|
703
|
+
}
|
|
704
|
+
return { outputPath: options.outputPath, success: true };
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
708
|
+
fs_1.default.unlinkSync(videoPath);
|
|
709
|
+
}
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Crop video
|
|
715
|
+
*/
|
|
716
|
+
async cropVideo(videoSource, options) {
|
|
717
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
718
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
719
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
720
|
+
}
|
|
721
|
+
const timestamp = Date.now();
|
|
722
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
723
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
724
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
725
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf "crop=${options.width}:${options.height}:${options.x}:${options.y}" -y "${escapedOutputPath}"`;
|
|
726
|
+
try {
|
|
727
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
728
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
729
|
+
fs_1.default.unlinkSync(videoPath);
|
|
730
|
+
}
|
|
731
|
+
return { outputPath: options.outputPath, success: true };
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
735
|
+
fs_1.default.unlinkSync(videoPath);
|
|
736
|
+
}
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Compress/Optimize video
|
|
742
|
+
*/
|
|
743
|
+
async compressVideo(videoSource, options) {
|
|
744
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
745
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
746
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
747
|
+
}
|
|
748
|
+
const timestamp = Date.now();
|
|
749
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
750
|
+
// Get original size
|
|
751
|
+
let originalSize = 0;
|
|
752
|
+
if (Buffer.isBuffer(videoSource)) {
|
|
753
|
+
originalSize = videoSource.length;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
originalSize = fs_1.default.statSync(videoPath).size;
|
|
757
|
+
}
|
|
758
|
+
const qualityPresets = {
|
|
759
|
+
low: '-crf 32 -preset fast',
|
|
760
|
+
medium: '-crf 28 -preset medium',
|
|
761
|
+
high: '-crf 23 -preset slow',
|
|
762
|
+
ultra: '-crf 18 -preset veryslow'
|
|
763
|
+
};
|
|
764
|
+
let qualityFlag = qualityPresets[options.quality || 'medium'];
|
|
765
|
+
if (options.maxBitrate) {
|
|
766
|
+
qualityFlag = `-b:v ${options.maxBitrate}k -maxrate ${options.maxBitrate}k -bufsize ${options.maxBitrate * 2}k`;
|
|
767
|
+
}
|
|
768
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
769
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
770
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${qualityFlag} -y "${escapedOutputPath}"`;
|
|
771
|
+
try {
|
|
772
|
+
await execAsync(command, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
773
|
+
const compressedSize = fs_1.default.existsSync(options.outputPath) ? fs_1.default.statSync(options.outputPath).size : 0;
|
|
774
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
775
|
+
fs_1.default.unlinkSync(videoPath);
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
outputPath: options.outputPath,
|
|
779
|
+
success: true,
|
|
780
|
+
originalSize,
|
|
781
|
+
compressedSize
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
786
|
+
fs_1.default.unlinkSync(videoPath);
|
|
787
|
+
}
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Add text overlay to video
|
|
793
|
+
*/
|
|
794
|
+
async addTextToVideo(videoSource, options) {
|
|
795
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
796
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
797
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
798
|
+
}
|
|
799
|
+
const timestamp = Date.now();
|
|
800
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
801
|
+
const position = options.position || 'bottom-center';
|
|
802
|
+
const fontSize = options.fontSize || 24;
|
|
803
|
+
const fontColor = options.fontColor || 'white';
|
|
804
|
+
const bgColor = options.backgroundColor || 'black@0.5';
|
|
805
|
+
const positionMap = {
|
|
806
|
+
'top-left': `x=10:y=10`,
|
|
807
|
+
'top-center': `x=(w-text_w)/2:y=10`,
|
|
808
|
+
'top-right': `x=w-text_w-10:y=10`,
|
|
809
|
+
'center': `x=(w-text_w)/2:y=(h-text_h)/2`,
|
|
810
|
+
'bottom-left': `x=10:y=h-text_h-10`,
|
|
811
|
+
'bottom-center': `x=(w-text_w)/2:y=h-text_h-10`,
|
|
812
|
+
'bottom-right': `x=w-text_w-10:y=h-text_h-10`
|
|
813
|
+
};
|
|
814
|
+
const pos = positionMap[position];
|
|
815
|
+
const textEscaped = options.text.replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
816
|
+
const timeFilter = options.startTime !== undefined && options.endTime !== undefined
|
|
817
|
+
? `:enable='between(t,${options.startTime},${options.endTime})'`
|
|
818
|
+
: '';
|
|
819
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
820
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
821
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf "drawtext=text='${textEscaped}':fontsize=${fontSize}:fontcolor=${fontColor}:box=1:boxcolor=${bgColor}:${pos}${timeFilter}" -y "${escapedOutputPath}"`;
|
|
822
|
+
try {
|
|
823
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
824
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
825
|
+
fs_1.default.unlinkSync(videoPath);
|
|
826
|
+
}
|
|
827
|
+
return { outputPath: options.outputPath, success: true };
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
831
|
+
fs_1.default.unlinkSync(videoPath);
|
|
832
|
+
}
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Create video from frames/images
|
|
838
|
+
*/
|
|
839
|
+
async createVideoFromFrames(options) {
|
|
840
|
+
if (!options.frames || options.frames.length === 0) {
|
|
841
|
+
throw new Error('createFromFrames: At least one frame is required');
|
|
842
|
+
}
|
|
843
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
844
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
845
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
846
|
+
}
|
|
847
|
+
const timestamp = Date.now();
|
|
848
|
+
const fps = options.fps || 30;
|
|
849
|
+
const format = options.format || 'mp4';
|
|
850
|
+
const qualityPresets = {
|
|
851
|
+
low: '-crf 28',
|
|
852
|
+
medium: '-crf 23',
|
|
853
|
+
high: '-crf 18',
|
|
854
|
+
ultra: '-crf 15'
|
|
855
|
+
};
|
|
856
|
+
const qualityFlag = options.bitrate
|
|
857
|
+
? `-b:v ${options.bitrate}k`
|
|
858
|
+
: qualityPresets[options.quality || 'medium'];
|
|
859
|
+
// Process frames: save buffers to temp files, resolve paths
|
|
860
|
+
const framePaths = [];
|
|
861
|
+
const tempFiles = [];
|
|
862
|
+
const frameSequenceDir = path_1.default.join(frameDir, `frames-${timestamp}`);
|
|
863
|
+
try {
|
|
864
|
+
// Get first frame dimensions if resolution not specified
|
|
865
|
+
let frameWidth;
|
|
866
|
+
let frameHeight;
|
|
867
|
+
if (options.resolution) {
|
|
868
|
+
frameWidth = options.resolution.width;
|
|
869
|
+
frameHeight = options.resolution.height;
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
// Load first frame to get dimensions
|
|
873
|
+
const firstFrame = options.frames[0];
|
|
874
|
+
let firstFramePath;
|
|
875
|
+
if (Buffer.isBuffer(firstFrame)) {
|
|
876
|
+
firstFramePath = path_1.default.join(frameDir, `frame-${timestamp}-0.png`);
|
|
877
|
+
fs_1.default.writeFileSync(firstFramePath, firstFrame);
|
|
878
|
+
tempFiles.push(firstFramePath);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
let resolvedPath = firstFrame;
|
|
882
|
+
if (!/^https?:\/\//i.test(resolvedPath)) {
|
|
883
|
+
resolvedPath = path_1.default.join(process.cwd(), resolvedPath);
|
|
884
|
+
}
|
|
885
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
886
|
+
throw new Error(`Frame file not found: ${firstFrame}`);
|
|
887
|
+
}
|
|
888
|
+
firstFramePath = resolvedPath;
|
|
889
|
+
}
|
|
890
|
+
// Get dimensions using ffprobe or loadImage
|
|
891
|
+
try {
|
|
892
|
+
const { loadImage } = await import('@napi-rs/canvas');
|
|
893
|
+
const img = await loadImage(firstFramePath);
|
|
894
|
+
frameWidth = img.width;
|
|
895
|
+
frameHeight = img.height;
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
// Fallback: try to get from ffprobe
|
|
899
|
+
const escapedPath = firstFramePath.replace(/"/g, '\\"');
|
|
900
|
+
try {
|
|
901
|
+
const { stdout } = await execAsync(`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of default=noprint_wrappers=1:nokey=1 "${escapedPath}"`, { timeout: 10000, maxBuffer: 1024 * 1024 });
|
|
902
|
+
const [w, h] = stdout.toString().trim().split('\n').map(Number);
|
|
903
|
+
if (w && h) {
|
|
904
|
+
frameWidth = w;
|
|
905
|
+
frameHeight = h;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
throw new Error('Could not determine frame dimensions. Please specify resolution.');
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// Process all frames - save all to temp directory with sequential naming for reliable pattern matching
|
|
914
|
+
if (!fs_1.default.existsSync(frameSequenceDir)) {
|
|
915
|
+
fs_1.default.mkdirSync(frameSequenceDir, { recursive: true });
|
|
916
|
+
}
|
|
917
|
+
for (let i = 0; i < options.frames.length; i++) {
|
|
918
|
+
const frame = options.frames[i];
|
|
919
|
+
let frameBuffer;
|
|
920
|
+
if (Buffer.isBuffer(frame)) {
|
|
921
|
+
frameBuffer = frame;
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
let resolvedPath = frame;
|
|
925
|
+
if (!/^https?:\/\//i.test(resolvedPath)) {
|
|
926
|
+
resolvedPath = path_1.default.join(process.cwd(), resolvedPath);
|
|
927
|
+
}
|
|
928
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
929
|
+
throw new Error(`Frame file not found: ${frame}`);
|
|
930
|
+
}
|
|
931
|
+
frameBuffer = fs_1.default.readFileSync(resolvedPath);
|
|
932
|
+
}
|
|
933
|
+
// Save with sequential naming (frame-000000.png, frame-000001.png, etc.)
|
|
934
|
+
const frameNumber = i.toString().padStart(6, '0');
|
|
935
|
+
const framePath = path_1.default.join(frameSequenceDir, `frame-${frameNumber}.png`);
|
|
936
|
+
fs_1.default.writeFileSync(framePath, frameBuffer);
|
|
937
|
+
tempFiles.push(framePath);
|
|
938
|
+
framePaths.push(framePath);
|
|
939
|
+
}
|
|
940
|
+
// Use image2 pattern input for reliable frame sequence
|
|
941
|
+
const patternPath = path_1.default.join(frameSequenceDir, 'frame-%06d.png').replace(/\\/g, '/');
|
|
942
|
+
const escapedPattern = patternPath.replace(/"/g, '\\"');
|
|
943
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
944
|
+
const resolutionFlag = frameWidth && frameHeight
|
|
945
|
+
? `-vf scale=${frameWidth}:${frameHeight}:force_original_aspect_ratio=decrease,pad=${frameWidth}:${frameHeight}:(ow-iw)/2:(oh-ih)/2`
|
|
946
|
+
: '';
|
|
947
|
+
// Use image2 demuxer with pattern for frame sequence
|
|
948
|
+
const command = `ffmpeg -framerate ${fps} -i "${escapedPattern}" ${resolutionFlag} ${qualityFlag} -pix_fmt yuv420p -y "${escapedOutputPath}"`;
|
|
949
|
+
await execAsync(command, {
|
|
950
|
+
timeout: 600000, // 10 minute timeout for large frame sequences
|
|
951
|
+
maxBuffer: 10 * 1024 * 1024
|
|
952
|
+
});
|
|
953
|
+
// Cleanup temp files and directory
|
|
954
|
+
for (const tempFile of tempFiles) {
|
|
955
|
+
if (fs_1.default.existsSync(tempFile)) {
|
|
956
|
+
try {
|
|
957
|
+
fs_1.default.unlinkSync(tempFile);
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// Ignore cleanup errors
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// Remove frame sequence directory
|
|
965
|
+
if (fs_1.default.existsSync(frameSequenceDir)) {
|
|
966
|
+
try {
|
|
967
|
+
fs_1.default.rmSync(frameSequenceDir, { recursive: true, force: true });
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// Ignore cleanup errors
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return { outputPath: options.outputPath, success: true };
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
// Cleanup temp files on error
|
|
977
|
+
for (const tempFile of tempFiles) {
|
|
978
|
+
if (fs_1.default.existsSync(tempFile)) {
|
|
979
|
+
try {
|
|
980
|
+
fs_1.default.unlinkSync(tempFile);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// Ignore cleanup errors
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// Remove frame sequence directory on error
|
|
988
|
+
if (fs_1.default.existsSync(frameSequenceDir)) {
|
|
989
|
+
try {
|
|
990
|
+
fs_1.default.rmSync(frameSequenceDir, { recursive: true, force: true });
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// Ignore cleanup errors
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Add fade effects to video
|
|
1001
|
+
*/
|
|
1002
|
+
async addFadeToVideo(videoSource, options) {
|
|
1003
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1004
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1005
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1006
|
+
}
|
|
1007
|
+
const timestamp = Date.now();
|
|
1008
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1009
|
+
const videoInfo = await this.deps.getVideoInfo(videoPath, true);
|
|
1010
|
+
const duration = videoInfo?.duration || 0;
|
|
1011
|
+
const filters = [];
|
|
1012
|
+
if (options.fadeIn) {
|
|
1013
|
+
filters.push(`fade=t=in:st=0:d=${options.fadeIn}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (options.fadeOut && duration > options.fadeOut) {
|
|
1016
|
+
filters.push(`fade=t=out:st=${duration - options.fadeOut}:d=${options.fadeOut}`);
|
|
1017
|
+
}
|
|
1018
|
+
const filterChain = filters.length > 0 ? `-vf "${filters.join(',')}"` : '';
|
|
1019
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1020
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1021
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${filterChain} -y "${escapedOutputPath}"`;
|
|
1022
|
+
try {
|
|
1023
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1024
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1025
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1026
|
+
}
|
|
1027
|
+
return { outputPath: options.outputPath, success: true };
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1031
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1032
|
+
}
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Reverse video playback
|
|
1038
|
+
*/
|
|
1039
|
+
async reverseVideo(videoSource, options) {
|
|
1040
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1041
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1042
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1043
|
+
}
|
|
1044
|
+
const timestamp = Date.now();
|
|
1045
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1046
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1047
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1048
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf reverse -af areverse -y "${escapedOutputPath}"`;
|
|
1049
|
+
try {
|
|
1050
|
+
await execAsync(command, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
1051
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1052
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1053
|
+
}
|
|
1054
|
+
return { outputPath: options.outputPath, success: true };
|
|
1055
|
+
}
|
|
1056
|
+
catch (error) {
|
|
1057
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1058
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1059
|
+
}
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Create seamless video loop
|
|
1065
|
+
*/
|
|
1066
|
+
async createVideoLoop(videoSource, options) {
|
|
1067
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1068
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1069
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1070
|
+
}
|
|
1071
|
+
const timestamp = Date.now();
|
|
1072
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1073
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1074
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1075
|
+
// Create loop by concatenating video with itself
|
|
1076
|
+
const concatFile = path_1.default.join(frameDir, `loop-${timestamp}.txt`);
|
|
1077
|
+
const concatContent = `file '${videoPath.replace(/'/g, "\\'")}'\nfile '${videoPath.replace(/'/g, "\\'")}'`;
|
|
1078
|
+
fs_1.default.writeFileSync(concatFile, concatContent);
|
|
1079
|
+
const command = `ffmpeg -f concat -safe 0 -i "${concatFile.replace(/"/g, '\\"')}" -c copy -y "${escapedOutputPath}"`;
|
|
1080
|
+
try {
|
|
1081
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1082
|
+
if (fs_1.default.existsSync(concatFile)) {
|
|
1083
|
+
fs_1.default.unlinkSync(concatFile);
|
|
1084
|
+
}
|
|
1085
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1086
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1087
|
+
}
|
|
1088
|
+
return { outputPath: options.outputPath, success: true };
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
if (fs_1.default.existsSync(concatFile)) {
|
|
1092
|
+
fs_1.default.unlinkSync(concatFile);
|
|
1093
|
+
}
|
|
1094
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1095
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1096
|
+
}
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Batch process multiple videos
|
|
1102
|
+
*/
|
|
1103
|
+
async batchProcessVideos(options) {
|
|
1104
|
+
if (!fs_1.default.existsSync(options.outputDirectory)) {
|
|
1105
|
+
fs_1.default.mkdirSync(options.outputDirectory, { recursive: true });
|
|
1106
|
+
}
|
|
1107
|
+
const results = [];
|
|
1108
|
+
for (let i = 0; i < options.videos.length; i++) {
|
|
1109
|
+
const video = options.videos[i];
|
|
1110
|
+
const outputPath = path_1.default.join(options.outputDirectory, `batch-${i + 1}.mp4`);
|
|
1111
|
+
try {
|
|
1112
|
+
// Process each video with its operations
|
|
1113
|
+
await this.deps.createVideo({
|
|
1114
|
+
source: video.source,
|
|
1115
|
+
...video.operations
|
|
1116
|
+
});
|
|
1117
|
+
results.push({
|
|
1118
|
+
source: typeof video.source === 'string' ? video.source : 'buffer',
|
|
1119
|
+
output: outputPath,
|
|
1120
|
+
success: true
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
results.push({
|
|
1125
|
+
source: typeof video.source === 'string' ? video.source : 'buffer',
|
|
1126
|
+
output: outputPath,
|
|
1127
|
+
success: false
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return results;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Detect scene changes in video
|
|
1135
|
+
*/
|
|
1136
|
+
async detectVideoScenes(videoSource, options) {
|
|
1137
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1138
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1139
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1140
|
+
}
|
|
1141
|
+
const timestamp = Date.now();
|
|
1142
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1143
|
+
const threshold = options.threshold || 0.3;
|
|
1144
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1145
|
+
const sceneFile = path_1.default.join(frameDir, `scenes-${timestamp}.txt`);
|
|
1146
|
+
// Use FFmpeg's scene detection
|
|
1147
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf "select='gt(scene,${threshold})',showinfo" -f null - 2>&1 | grep "pts_time" | awk '{print $6}' | sed 's/time=//'`;
|
|
1148
|
+
try {
|
|
1149
|
+
const { stdout } = await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1150
|
+
const times = stdout.toString().trim().split('\n').filter(t => t).map(parseFloat);
|
|
1151
|
+
const scenes = times.map((time, index) => ({ time, scene: index + 1 }));
|
|
1152
|
+
if (options.outputPath && scenes.length > 0) {
|
|
1153
|
+
fs_1.default.writeFileSync(options.outputPath, JSON.stringify(scenes, null, 2));
|
|
1154
|
+
}
|
|
1155
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1156
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1157
|
+
}
|
|
1158
|
+
if (fs_1.default.existsSync(sceneFile)) {
|
|
1159
|
+
fs_1.default.unlinkSync(sceneFile);
|
|
1160
|
+
}
|
|
1161
|
+
return scenes;
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1165
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1166
|
+
}
|
|
1167
|
+
if (fs_1.default.existsSync(sceneFile)) {
|
|
1168
|
+
fs_1.default.unlinkSync(sceneFile);
|
|
1169
|
+
}
|
|
1170
|
+
// Return empty array if detection fails
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Stabilize video (reduce shake)
|
|
1176
|
+
*/
|
|
1177
|
+
async stabilizeVideo(videoSource, options) {
|
|
1178
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1179
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1180
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1181
|
+
}
|
|
1182
|
+
const timestamp = Date.now();
|
|
1183
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1184
|
+
const smoothing = options.smoothing || 10;
|
|
1185
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1186
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1187
|
+
// Two-pass stabilization
|
|
1188
|
+
const transformsFile = path_1.default.join(frameDir, `transforms-${timestamp}.trf`);
|
|
1189
|
+
// Pass 1: Analyze
|
|
1190
|
+
const analyzeCommand = `ffmpeg -i "${escapedVideoPath}" -vf vidstabdetect=shakiness=5:accuracy=15:result="${transformsFile.replace(/"/g, '\\"')}" -f null -`;
|
|
1191
|
+
// Pass 2: Transform
|
|
1192
|
+
const transformCommand = `ffmpeg -i "${escapedVideoPath}" -vf vidstabtransform=smoothing=${smoothing}:input="${transformsFile.replace(/"/g, '\\"')}" -y "${escapedOutputPath}"`;
|
|
1193
|
+
try {
|
|
1194
|
+
await execAsync(analyzeCommand, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
1195
|
+
await execAsync(transformCommand, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
1196
|
+
if (fs_1.default.existsSync(transformsFile)) {
|
|
1197
|
+
fs_1.default.unlinkSync(transformsFile);
|
|
1198
|
+
}
|
|
1199
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1200
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1201
|
+
}
|
|
1202
|
+
return { outputPath: options.outputPath, success: true };
|
|
1203
|
+
}
|
|
1204
|
+
catch (error) {
|
|
1205
|
+
// Fallback to simple deshake if vidstab is not available
|
|
1206
|
+
const simpleCommand = `ffmpeg -i "${escapedVideoPath}" -vf "hqdn3d=4:3:6:4.5" -y "${escapedOutputPath}"`;
|
|
1207
|
+
try {
|
|
1208
|
+
await execAsync(simpleCommand, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1209
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1210
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1211
|
+
}
|
|
1212
|
+
return { outputPath: options.outputPath, success: true };
|
|
1213
|
+
}
|
|
1214
|
+
catch (fallbackError) {
|
|
1215
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1216
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1217
|
+
}
|
|
1218
|
+
throw error;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Color correct video
|
|
1224
|
+
*/
|
|
1225
|
+
async colorCorrectVideo(videoSource, options) {
|
|
1226
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1227
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1228
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1229
|
+
}
|
|
1230
|
+
const timestamp = Date.now();
|
|
1231
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1232
|
+
const filters = [];
|
|
1233
|
+
if (options.brightness !== undefined) {
|
|
1234
|
+
filters.push(`eq=brightness=${(options.brightness / 100).toFixed(2)}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (options.contrast !== undefined) {
|
|
1237
|
+
filters.push(`eq=contrast=${1 + (options.contrast / 100)}`);
|
|
1238
|
+
}
|
|
1239
|
+
if (options.saturation !== undefined) {
|
|
1240
|
+
filters.push(`eq=saturation=${1 + (options.saturation / 100)}`);
|
|
1241
|
+
}
|
|
1242
|
+
if (options.hue !== undefined) {
|
|
1243
|
+
filters.push(`hue=h=${options.hue}`);
|
|
1244
|
+
}
|
|
1245
|
+
if (options.temperature !== undefined) {
|
|
1246
|
+
// Temperature adjustment using colorbalance
|
|
1247
|
+
const temp = options.temperature;
|
|
1248
|
+
if (temp > 0) {
|
|
1249
|
+
filters.push(`colorbalance=rs=${temp / 100}:gs=-${temp / 200}:bs=-${temp / 100}`);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
filters.push(`colorbalance=rs=${temp / 100}:gs=${-temp / 200}:bs=${-temp / 100}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const filterChain = filters.length > 0 ? `-vf "${filters.join(',')}"` : '';
|
|
1256
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1257
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1258
|
+
const command = `ffmpeg -i "${escapedVideoPath}" ${filterChain} -y "${escapedOutputPath}"`;
|
|
1259
|
+
try {
|
|
1260
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1261
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1262
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1263
|
+
}
|
|
1264
|
+
return { outputPath: options.outputPath, success: true };
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1268
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1269
|
+
}
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Add picture-in-picture
|
|
1275
|
+
*/
|
|
1276
|
+
async addPictureInPicture(videoSource, options) {
|
|
1277
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1278
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1279
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1280
|
+
}
|
|
1281
|
+
const timestamp = Date.now();
|
|
1282
|
+
// Handle main video
|
|
1283
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1284
|
+
// Handle overlay video
|
|
1285
|
+
const { videoPath: overlayPath, shouldCleanup: shouldCleanupOverlay } = await resolveVideoSource(options.overlayVideo, frameDir, timestamp + 1000);
|
|
1286
|
+
const position = options.position || 'bottom-right';
|
|
1287
|
+
const size = options.size || { width: 320, height: 180 };
|
|
1288
|
+
const opacity = options.opacity || 1.0;
|
|
1289
|
+
const positionMap = {
|
|
1290
|
+
'top-left': '10:10',
|
|
1291
|
+
'top-right': 'W-w-10:10',
|
|
1292
|
+
'bottom-left': '10:H-h-10',
|
|
1293
|
+
'bottom-right': 'W-w-10:H-h-10',
|
|
1294
|
+
'center': '(W-w)/2:(H-h)/2'
|
|
1295
|
+
};
|
|
1296
|
+
const overlay = positionMap[position];
|
|
1297
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1298
|
+
const escapedOverlayPath = overlayPath.replace(/"/g, '\\"');
|
|
1299
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1300
|
+
const filter = `[1:v]scale=${size.width}:${size.height},format=rgba,colorchannelmixer=aa=${opacity}[overlay];[0:v][overlay]overlay=${overlay}`;
|
|
1301
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -i "${escapedOverlayPath}" -filter_complex "${filter}" -y "${escapedOutputPath}"`;
|
|
1302
|
+
try {
|
|
1303
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1304
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1305
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1306
|
+
}
|
|
1307
|
+
if (shouldCleanupOverlay && fs_1.default.existsSync(overlayPath)) {
|
|
1308
|
+
fs_1.default.unlinkSync(overlayPath);
|
|
1309
|
+
}
|
|
1310
|
+
return { outputPath: options.outputPath, success: true };
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1314
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1315
|
+
}
|
|
1316
|
+
if (shouldCleanupOverlay && fs_1.default.existsSync(overlayPath)) {
|
|
1317
|
+
fs_1.default.unlinkSync(overlayPath);
|
|
1318
|
+
}
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Create split screen video
|
|
1324
|
+
*/
|
|
1325
|
+
async createSplitScreen(options) {
|
|
1326
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1327
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1328
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1329
|
+
}
|
|
1330
|
+
const timestamp = Date.now();
|
|
1331
|
+
const videoPaths = [];
|
|
1332
|
+
const shouldCleanup = [];
|
|
1333
|
+
// Prepare all video files
|
|
1334
|
+
for (let i = 0; i < options.videos.length; i++) {
|
|
1335
|
+
const video = options.videos[i];
|
|
1336
|
+
if (Buffer.isBuffer(video)) {
|
|
1337
|
+
const tempPath = path_1.default.join(frameDir, `temp-video-${timestamp}-${i}.mp4`);
|
|
1338
|
+
fs_1.default.writeFileSync(tempPath, video);
|
|
1339
|
+
videoPaths.push(tempPath);
|
|
1340
|
+
shouldCleanup.push(true);
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
let resolvedPath = video;
|
|
1344
|
+
if (!/^https?:\/\//i.test(resolvedPath)) {
|
|
1345
|
+
resolvedPath = path_1.default.join(process.cwd(), resolvedPath);
|
|
1346
|
+
}
|
|
1347
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
1348
|
+
throw new Error(`Video file not found: ${video}`);
|
|
1349
|
+
}
|
|
1350
|
+
videoPaths.push(resolvedPath);
|
|
1351
|
+
shouldCleanup.push(false);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
const layout = options.layout || 'side-by-side';
|
|
1355
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1356
|
+
const escapedPaths = videoPaths.map(vp => vp.replace(/"/g, '\\"'));
|
|
1357
|
+
let command;
|
|
1358
|
+
if (layout === 'side-by-side' && videoPaths.length >= 2) {
|
|
1359
|
+
command = `ffmpeg -i "${escapedPaths[0]}" -i "${escapedPaths[1]}" -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
1360
|
+
}
|
|
1361
|
+
else if (layout === 'top-bottom' && videoPaths.length >= 2) {
|
|
1362
|
+
command = `ffmpeg -i "${escapedPaths[0]}" -i "${escapedPaths[1]}" -filter_complex "[0:v][1:v]vstack=inputs=2[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
1363
|
+
}
|
|
1364
|
+
else if (layout === 'grid' && videoPaths.length >= 4) {
|
|
1365
|
+
const grid = options.grid || { cols: 2, rows: 2 };
|
|
1366
|
+
// Simplified 2x2 grid
|
|
1367
|
+
command = `ffmpeg -i "${escapedPaths[0]}" -i "${escapedPaths[1]}" -i "${escapedPaths[2]}" -i "${escapedPaths[3]}" -filter_complex "[0:v][1:v]hstack=inputs=2[top];[2:v][3:v]hstack=inputs=2[bottom];[top][bottom]vstack=inputs=2[v]" -map "[v]" -y "${escapedOutputPath}"`;
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
throw new Error(`Invalid layout or insufficient videos for ${layout}`);
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
await execAsync(command, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 });
|
|
1374
|
+
// Cleanup
|
|
1375
|
+
for (let i = 0; i < videoPaths.length; i++) {
|
|
1376
|
+
if (shouldCleanup[i] && fs_1.default.existsSync(videoPaths[i])) {
|
|
1377
|
+
fs_1.default.unlinkSync(videoPaths[i]);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return { outputPath: options.outputPath, success: true };
|
|
1381
|
+
}
|
|
1382
|
+
catch (error) {
|
|
1383
|
+
// Cleanup on error
|
|
1384
|
+
for (let i = 0; i < videoPaths.length; i++) {
|
|
1385
|
+
if (shouldCleanup[i] && fs_1.default.existsSync(videoPaths[i])) {
|
|
1386
|
+
fs_1.default.unlinkSync(videoPaths[i]);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
throw error;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Create time-lapse video
|
|
1394
|
+
*/
|
|
1395
|
+
async createTimeLapseVideo(videoSource, options) {
|
|
1396
|
+
const speed = options.speed || 10;
|
|
1397
|
+
// Time-lapse is essentially speeding up the video
|
|
1398
|
+
return await this.changeVideoSpeed(videoSource, { speed, outputPath: options.outputPath });
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Mute video (remove audio) - supports full mute or partial mute with time ranges
|
|
1402
|
+
*/
|
|
1403
|
+
async muteVideo(videoSource, options) {
|
|
1404
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1405
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1406
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1407
|
+
}
|
|
1408
|
+
const timestamp = Date.now();
|
|
1409
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1410
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1411
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1412
|
+
let command;
|
|
1413
|
+
if (options.ranges && options.ranges.length > 0) {
|
|
1414
|
+
// Partial mute - mute specific time ranges
|
|
1415
|
+
const volumeFilters = options.ranges.map(range => `volume=enable='between(t,${range.start},${range.end})':volume=0`).join(',');
|
|
1416
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "${volumeFilters}" -y "${escapedOutputPath}"`;
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
// Full mute - remove audio track
|
|
1420
|
+
command = `ffmpeg -i "${escapedVideoPath}" -an -y "${escapedOutputPath}"`;
|
|
1421
|
+
}
|
|
1422
|
+
try {
|
|
1423
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1424
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1425
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1426
|
+
}
|
|
1427
|
+
return { outputPath: options.outputPath, success: true };
|
|
1428
|
+
}
|
|
1429
|
+
catch (error) {
|
|
1430
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1431
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1432
|
+
}
|
|
1433
|
+
throw error;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Adjust video volume
|
|
1438
|
+
*/
|
|
1439
|
+
async adjustVideoVolume(videoSource, options) {
|
|
1440
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1441
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1442
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1443
|
+
}
|
|
1444
|
+
const timestamp = Date.now();
|
|
1445
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1446
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1447
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1448
|
+
let command;
|
|
1449
|
+
if (options.ranges && options.ranges.length > 0) {
|
|
1450
|
+
// Volume adjustment for specific time ranges
|
|
1451
|
+
const volumeFilters = options.ranges.map(range => {
|
|
1452
|
+
const volumeMultiplier = range.volume / 100;
|
|
1453
|
+
return `volume=enable='between(t,${range.start},${range.end})':volume=${volumeMultiplier}`;
|
|
1454
|
+
}).join(',');
|
|
1455
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "${volumeFilters}" -y "${escapedOutputPath}"`;
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
// Global volume adjustment
|
|
1459
|
+
const volumeMultiplier = (options.volume || 100) / 100;
|
|
1460
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "volume=${volumeMultiplier}" -y "${escapedOutputPath}"`;
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
await execAsync(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1464
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1465
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1466
|
+
}
|
|
1467
|
+
return { outputPath: options.outputPath, success: true };
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1471
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1472
|
+
}
|
|
1473
|
+
throw error;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Freeze video frame at specific time
|
|
1478
|
+
*/
|
|
1479
|
+
async freezeVideoFrame(videoSource, options, onProgress) {
|
|
1480
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1481
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1482
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1483
|
+
}
|
|
1484
|
+
const timestamp = Date.now();
|
|
1485
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1486
|
+
// Get video info
|
|
1487
|
+
const videoInfo = await this.deps.getVideoInfo(videoPath, true);
|
|
1488
|
+
if (!videoInfo) {
|
|
1489
|
+
throw new Error('Failed to get video information');
|
|
1490
|
+
}
|
|
1491
|
+
if (options.time < 0 || options.time > videoInfo.duration) {
|
|
1492
|
+
throw new Error(`Freeze time (${options.time}s) is outside video duration (${videoInfo.duration}s)`);
|
|
1493
|
+
}
|
|
1494
|
+
try {
|
|
1495
|
+
// Extract frame at freeze time
|
|
1496
|
+
const freezeFramePath = path_1.default.join(frameDir, `freeze-frame-${timestamp}.png`);
|
|
1497
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1498
|
+
const escapedFramePath = freezeFramePath.replace(/"/g, '\\"');
|
|
1499
|
+
await execAsync(`ffmpeg -i "${escapedVideoPath}" -ss ${options.time} -vframes 1 -y "${escapedFramePath}"`, { timeout: 30000, maxBuffer: 10 * 1024 * 1024 });
|
|
1500
|
+
// Create video parts: before freeze, freeze frame (repeated), after freeze
|
|
1501
|
+
const part1Path = path_1.default.join(frameDir, `part1-${timestamp}.mp4`);
|
|
1502
|
+
const freezePartPath = path_1.default.join(frameDir, `freeze-part-${timestamp}.mp4`);
|
|
1503
|
+
const part3Path = path_1.default.join(frameDir, `part3-${timestamp}.mp4`);
|
|
1504
|
+
const concatFile = path_1.default.join(frameDir, `concat-${timestamp}.txt`);
|
|
1505
|
+
const escapedPart1 = part1Path.replace(/"/g, '\\"');
|
|
1506
|
+
const escapedFreeze = freezePartPath.replace(/"/g, '\\"');
|
|
1507
|
+
const escapedPart3 = part3Path.replace(/"/g, '\\"');
|
|
1508
|
+
const escapedOutput = options.outputPath.replace(/"/g, '\\"');
|
|
1509
|
+
// Part 1: Before freeze
|
|
1510
|
+
if (options.time > 0) {
|
|
1511
|
+
await execAsync(`ffmpeg -i "${escapedVideoPath}" -t ${options.time} -c copy -y "${escapedPart1}"`, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1512
|
+
}
|
|
1513
|
+
// Freeze part: Repeat frame for duration
|
|
1514
|
+
await execAsync(`ffmpeg -loop 1 -i "${escapedFramePath}" -t ${options.duration} -c:v libx264 -pix_fmt yuv420p -y "${escapedFreeze}"`, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1515
|
+
// Part 3: After freeze
|
|
1516
|
+
const remainingDuration = videoInfo.duration - options.time;
|
|
1517
|
+
if (remainingDuration > 0) {
|
|
1518
|
+
await execAsync(`ffmpeg -i "${escapedVideoPath}" -ss ${options.time} -c copy -y "${escapedPart3}"`, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
|
|
1519
|
+
}
|
|
1520
|
+
// Create concat file
|
|
1521
|
+
const concatParts = [];
|
|
1522
|
+
if (options.time > 0 && fs_1.default.existsSync(part1Path)) {
|
|
1523
|
+
concatParts.push(part1Path.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
1524
|
+
}
|
|
1525
|
+
if (fs_1.default.existsSync(freezePartPath)) {
|
|
1526
|
+
concatParts.push(freezePartPath.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
1527
|
+
}
|
|
1528
|
+
if (remainingDuration > 0 && fs_1.default.existsSync(part3Path)) {
|
|
1529
|
+
concatParts.push(part3Path.replace(/\\/g, '/').replace(/'/g, "\\'"));
|
|
1530
|
+
}
|
|
1531
|
+
const concatContent = concatParts.map(p => `file '${p}'`).join('\n');
|
|
1532
|
+
fs_1.default.writeFileSync(concatFile, concatContent);
|
|
1533
|
+
// Concatenate
|
|
1534
|
+
const escapedConcat = concatFile.replace(/"/g, '\\"');
|
|
1535
|
+
await this.executeFFmpegWithProgress(`ffmpeg -f concat -safe 0 -i "${escapedConcat}" -c copy -y "${escapedOutput}"`, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 }, onProgress);
|
|
1536
|
+
// Cleanup
|
|
1537
|
+
const tempFiles = [freezeFramePath, part1Path, freezePartPath, part3Path, concatFile];
|
|
1538
|
+
for (const file of tempFiles) {
|
|
1539
|
+
if (fs_1.default.existsSync(file)) {
|
|
1540
|
+
try {
|
|
1541
|
+
fs_1.default.unlinkSync(file);
|
|
1542
|
+
}
|
|
1543
|
+
catch { }
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1547
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1548
|
+
}
|
|
1549
|
+
return { outputPath: options.outputPath, success: true };
|
|
1550
|
+
}
|
|
1551
|
+
catch (error) {
|
|
1552
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1553
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1554
|
+
}
|
|
1555
|
+
throw error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Export video with preset settings
|
|
1560
|
+
*/
|
|
1561
|
+
async exportVideoPreset(videoSource, options, onProgress) {
|
|
1562
|
+
const presets = {
|
|
1563
|
+
youtube: { resolution: { width: 1920, height: 1080 }, fps: 30, bitrate: 8000, format: 'mp4' },
|
|
1564
|
+
instagram: { resolution: { width: 1080, height: 1080 }, fps: 30, bitrate: 3500, format: 'mp4' },
|
|
1565
|
+
tiktok: { resolution: { width: 1080, height: 1920 }, fps: 30, bitrate: 4000, format: 'mp4' },
|
|
1566
|
+
twitter: { resolution: { width: 1280, height: 720 }, fps: 30, bitrate: 5000, format: 'mp4' },
|
|
1567
|
+
facebook: { resolution: { width: 1280, height: 720 }, fps: 30, bitrate: 4000, format: 'mp4' },
|
|
1568
|
+
'4k': { resolution: { width: 3840, height: 2160 }, fps: 30, bitrate: 50000, format: 'mp4' },
|
|
1569
|
+
'1080p': { resolution: { width: 1920, height: 1080 }, fps: 30, bitrate: 8000, format: 'mp4' },
|
|
1570
|
+
'720p': { resolution: { width: 1280, height: 720 }, fps: 30, bitrate: 5000, format: 'mp4' },
|
|
1571
|
+
mobile: { resolution: { width: 720, height: 1280 }, fps: 30, bitrate: 2500, format: 'mp4' },
|
|
1572
|
+
web: { resolution: { width: 1280, height: 720 }, fps: 30, bitrate: 3000, format: 'webm' }
|
|
1573
|
+
};
|
|
1574
|
+
const preset = presets[options.preset.toLowerCase()];
|
|
1575
|
+
if (!preset) {
|
|
1576
|
+
throw new Error(`Unknown export preset: ${options.preset}`);
|
|
1577
|
+
}
|
|
1578
|
+
return await this.convertVideo(videoSource, {
|
|
1579
|
+
outputPath: options.outputPath,
|
|
1580
|
+
format: preset.format,
|
|
1581
|
+
quality: 'high',
|
|
1582
|
+
bitrate: preset.bitrate,
|
|
1583
|
+
fps: preset.fps,
|
|
1584
|
+
resolution: preset.resolution
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Normalize audio levels
|
|
1589
|
+
*/
|
|
1590
|
+
async normalizeVideoAudio(videoSource, options, onProgress) {
|
|
1591
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1592
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1593
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1594
|
+
}
|
|
1595
|
+
const timestamp = Date.now();
|
|
1596
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1597
|
+
const method = options.method || 'lufs';
|
|
1598
|
+
const targetLevel = options.targetLevel || (method === 'lufs' ? -23 : -1);
|
|
1599
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1600
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1601
|
+
let command;
|
|
1602
|
+
if (method === 'lufs') {
|
|
1603
|
+
// Use loudnorm for LUFS (broadcast standard)
|
|
1604
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "loudnorm=I=${targetLevel}:TP=-1.5:LRA=11" -c:v copy -y "${escapedOutputPath}"`;
|
|
1605
|
+
}
|
|
1606
|
+
else if (method === 'peak') {
|
|
1607
|
+
// Peak normalization
|
|
1608
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "volume=${targetLevel}dB" -c:v copy -y "${escapedOutputPath}"`;
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
// RMS normalization
|
|
1612
|
+
command = `ffmpeg -i "${escapedVideoPath}" -af "volume=${targetLevel}dB" -c:v copy -y "${escapedOutputPath}"`;
|
|
1613
|
+
}
|
|
1614
|
+
try {
|
|
1615
|
+
await this.executeFFmpegWithProgress(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 }, onProgress);
|
|
1616
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1617
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1618
|
+
}
|
|
1619
|
+
return { outputPath: options.outputPath, success: true };
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1623
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1624
|
+
}
|
|
1625
|
+
throw error;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Apply LUT (Look-Up Table) to video
|
|
1630
|
+
*/
|
|
1631
|
+
async applyLUTToVideo(videoSource, options, onProgress) {
|
|
1632
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1633
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1634
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1635
|
+
}
|
|
1636
|
+
const timestamp = Date.now();
|
|
1637
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1638
|
+
// Resolve LUT path
|
|
1639
|
+
let lutPath = options.lutPath;
|
|
1640
|
+
if (!/^https?:\/\//i.test(lutPath)) {
|
|
1641
|
+
lutPath = path_1.default.join(process.cwd(), lutPath);
|
|
1642
|
+
}
|
|
1643
|
+
if (!fs_1.default.existsSync(lutPath)) {
|
|
1644
|
+
throw new Error(`LUT file not found: ${options.lutPath}`);
|
|
1645
|
+
}
|
|
1646
|
+
const intensity = options.intensity ?? 1.0;
|
|
1647
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1648
|
+
const escapedLutPath = lutPath.replace(/"/g, '\\"');
|
|
1649
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1650
|
+
// Apply LUT with intensity blending
|
|
1651
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf "lut3d='${escapedLutPath}',format=yuv420p" -c:v libx264 -crf 18 -y "${escapedOutputPath}"`;
|
|
1652
|
+
try {
|
|
1653
|
+
await this.executeFFmpegWithProgress(command, { timeout: 600000, maxBuffer: 10 * 1024 * 1024 }, onProgress);
|
|
1654
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1655
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1656
|
+
}
|
|
1657
|
+
return { outputPath: options.outputPath, success: true };
|
|
1658
|
+
}
|
|
1659
|
+
catch (error) {
|
|
1660
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1661
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1662
|
+
}
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Add video transition between two videos
|
|
1668
|
+
*/
|
|
1669
|
+
async addVideoTransition(videoSource, options, onProgress) {
|
|
1670
|
+
if (!options.secondVideo && options.type !== 'fade') {
|
|
1671
|
+
throw new Error('addTransition: secondVideo is required for transition types other than fade');
|
|
1672
|
+
}
|
|
1673
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1674
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1675
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1676
|
+
}
|
|
1677
|
+
const timestamp = Date.now();
|
|
1678
|
+
// Prepare first video
|
|
1679
|
+
const { videoPath: video1Path, shouldCleanup: shouldCleanup1 } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1680
|
+
// Prepare second video (if provided)
|
|
1681
|
+
let video2Path;
|
|
1682
|
+
let shouldCleanup2 = false;
|
|
1683
|
+
if (options.secondVideo) {
|
|
1684
|
+
const { videoPath: video2PathResolved, shouldCleanup: shouldCleanup2Value } = await resolveVideoSource(options.secondVideo, frameDir, timestamp + 1000);
|
|
1685
|
+
video2Path = video2PathResolved;
|
|
1686
|
+
shouldCleanup2 = shouldCleanup2Value;
|
|
1687
|
+
}
|
|
1688
|
+
// Get video info
|
|
1689
|
+
const video1Info = await this.deps.getVideoInfo(video1Path, true);
|
|
1690
|
+
if (!video1Info) {
|
|
1691
|
+
throw new Error('Failed to get video information');
|
|
1692
|
+
}
|
|
1693
|
+
// Ensure videos have same resolution
|
|
1694
|
+
const width = video1Info.width;
|
|
1695
|
+
const height = video1Info.height;
|
|
1696
|
+
const escapedVideo1 = video1Path.replace(/"/g, '\\"');
|
|
1697
|
+
const escapedOutput = options.outputPath.replace(/"/g, '\\"');
|
|
1698
|
+
// Handle fade transition (can work on single video)
|
|
1699
|
+
if (options.type === 'fade' && !options.secondVideo) {
|
|
1700
|
+
// Fade in/out on single video
|
|
1701
|
+
const fadeType = options.direction === 'out' ? 'fade=t=out' : 'fade=t=in';
|
|
1702
|
+
const command = `ffmpeg -i "${escapedVideo1}" -vf "${fadeType}:st=0:d=${options.duration}" -c:a copy -y "${escapedOutput}"`;
|
|
1703
|
+
try {
|
|
1704
|
+
await this.executeFFmpegWithProgress(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 }, onProgress);
|
|
1705
|
+
if (shouldCleanup1 && fs_1.default.existsSync(video1Path)) {
|
|
1706
|
+
fs_1.default.unlinkSync(video1Path);
|
|
1707
|
+
}
|
|
1708
|
+
return { outputPath: options.outputPath, success: true };
|
|
1709
|
+
}
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
if (shouldCleanup1 && fs_1.default.existsSync(video1Path)) {
|
|
1712
|
+
fs_1.default.unlinkSync(video1Path);
|
|
1713
|
+
}
|
|
1714
|
+
throw error;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (!video2Path) {
|
|
1718
|
+
throw new Error('Second video is required for this transition type');
|
|
1719
|
+
}
|
|
1720
|
+
const video2Info = await this.deps.getVideoInfo(video2Path, true);
|
|
1721
|
+
if (!video2Info) {
|
|
1722
|
+
throw new Error('Failed to get second video information');
|
|
1723
|
+
}
|
|
1724
|
+
const finalWidth = Math.max(width, video2Info.width);
|
|
1725
|
+
const finalHeight = Math.max(height, video2Info.height);
|
|
1726
|
+
const escapedVideo2 = video2Path.replace(/"/g, '\\"');
|
|
1727
|
+
// Map transition types to FFmpeg xfade types
|
|
1728
|
+
const xfadeTypes = {
|
|
1729
|
+
fade: 'fade',
|
|
1730
|
+
wipe: 'wipeleft',
|
|
1731
|
+
slide: 'slideleft',
|
|
1732
|
+
zoom: 'zoom',
|
|
1733
|
+
rotate: 'rotate',
|
|
1734
|
+
dissolve: 'fade',
|
|
1735
|
+
blur: 'fade',
|
|
1736
|
+
circle: 'circleopen',
|
|
1737
|
+
pixelize: 'pixelize'
|
|
1738
|
+
};
|
|
1739
|
+
let xfadeType = xfadeTypes[options.type] || 'fade';
|
|
1740
|
+
// Handle direction modifiers
|
|
1741
|
+
if (options.direction) {
|
|
1742
|
+
const dirMap = {
|
|
1743
|
+
wipe: { left: 'wipeleft', right: 'wiperight', up: 'wipeup', down: 'wipedown' },
|
|
1744
|
+
slide: { left: 'slideleft', right: 'slideright', up: 'slideup', down: 'slidedown' },
|
|
1745
|
+
zoom: { in: 'zoomin', out: 'zoomout' }
|
|
1746
|
+
};
|
|
1747
|
+
if (dirMap[options.type] && dirMap[options.type][options.direction]) {
|
|
1748
|
+
xfadeType = dirMap[options.type][options.direction];
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const transitionOffset = video1Info.duration - options.duration;
|
|
1752
|
+
// Build FFmpeg command with xfade filter
|
|
1753
|
+
const command = `ffmpeg -i "${escapedVideo1}" -i "${escapedVideo2}" -filter_complex "[0:v]scale=${finalWidth}:${finalHeight}[v0];[1:v]scale=${finalWidth}:${finalHeight}[v1];[v0][v1]xfade=transition=${xfadeType}:duration=${options.duration}:offset=${transitionOffset}[v]" -map "[v]" -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a copy -y "${escapedOutput}"`;
|
|
1754
|
+
try {
|
|
1755
|
+
await this.executeFFmpegWithProgress(command, { timeout: 600000, maxBuffer: 20 * 1024 * 1024 }, onProgress);
|
|
1756
|
+
if (shouldCleanup1 && fs_1.default.existsSync(video1Path)) {
|
|
1757
|
+
fs_1.default.unlinkSync(video1Path);
|
|
1758
|
+
}
|
|
1759
|
+
if (shouldCleanup2 && fs_1.default.existsSync(video2Path)) {
|
|
1760
|
+
fs_1.default.unlinkSync(video2Path);
|
|
1761
|
+
}
|
|
1762
|
+
return { outputPath: options.outputPath, success: true };
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
if (shouldCleanup1 && fs_1.default.existsSync(video1Path)) {
|
|
1766
|
+
fs_1.default.unlinkSync(video1Path);
|
|
1767
|
+
}
|
|
1768
|
+
if (shouldCleanup2 && fs_1.default.existsSync(video2Path)) {
|
|
1769
|
+
fs_1.default.unlinkSync(video2Path);
|
|
1770
|
+
}
|
|
1771
|
+
throw error;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Add animated text to video
|
|
1776
|
+
*/
|
|
1777
|
+
async addAnimatedTextToVideo(videoSource, options, onProgress) {
|
|
1778
|
+
const frameDir = path_1.default.join(process.cwd(), '.temp-frames');
|
|
1779
|
+
if (!fs_1.default.existsSync(frameDir)) {
|
|
1780
|
+
fs_1.default.mkdirSync(frameDir, { recursive: true });
|
|
1781
|
+
}
|
|
1782
|
+
const timestamp = Date.now();
|
|
1783
|
+
const { videoPath, shouldCleanup: shouldCleanupVideo } = await resolveVideoSource(videoSource, frameDir, timestamp);
|
|
1784
|
+
const fontSize = options.fontSize || 24;
|
|
1785
|
+
const fontColor = options.fontColor || 'white';
|
|
1786
|
+
const bgColor = options.backgroundColor || 'black@0.5';
|
|
1787
|
+
const animation = options.animation || 'none';
|
|
1788
|
+
const duration = options.endTime - options.startTime;
|
|
1789
|
+
// Build position string
|
|
1790
|
+
let positionStr;
|
|
1791
|
+
if (typeof options.position === 'string') {
|
|
1792
|
+
const positionMap = {
|
|
1793
|
+
'top-left': 'x=10:y=10',
|
|
1794
|
+
'top-center': 'x=(w-text_w)/2:y=10',
|
|
1795
|
+
'top-right': 'x=w-text_w-10:y=10',
|
|
1796
|
+
'center': 'x=(w-text_w)/2:y=(h-text_h)/2',
|
|
1797
|
+
'bottom-left': 'x=10:y=h-text_h-10',
|
|
1798
|
+
'bottom-center': 'x=(w-text_w)/2:y=h-text_h-10',
|
|
1799
|
+
'bottom-right': 'x=w-text_w-10:y=h-text_h-10'
|
|
1800
|
+
};
|
|
1801
|
+
positionStr = positionMap[options.position] || positionMap['bottom-center'];
|
|
1802
|
+
}
|
|
1803
|
+
else {
|
|
1804
|
+
positionStr = `x=${options.position?.x || 10}:y=${options.position?.y || 10}`;
|
|
1805
|
+
}
|
|
1806
|
+
// Build animation filter
|
|
1807
|
+
let animationFilter = '';
|
|
1808
|
+
if (animation === 'fade') {
|
|
1809
|
+
animationFilter = `:alpha='if(lt(t,${options.startTime}),0,if(lt(t,${options.startTime}+1), (t-${options.startTime})/1, if(lt(t,${options.endTime}-1), 1, if(lt(t,${options.endTime}), (${options.endTime}-t)/1, 0))))'`;
|
|
1810
|
+
}
|
|
1811
|
+
else if (animation === 'slide') {
|
|
1812
|
+
animationFilter = `:x='if(lt(t,${options.startTime}),-text_w,if(lt(t,${options.startTime}+1), (w-text_w)*(t-${options.startTime})/1, if(lt(t,${options.endTime}-1), w-text_w-10, if(lt(t,${options.endTime}), w-text_w-10+(w)*(t-${options.endTime})/1, w))))'`;
|
|
1813
|
+
}
|
|
1814
|
+
const textEscaped = options.text.replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
1815
|
+
const escapedVideoPath = videoPath.replace(/"/g, '\\"');
|
|
1816
|
+
const escapedOutputPath = options.outputPath.replace(/"/g, '\\"');
|
|
1817
|
+
const drawtextFilter = `drawtext=text='${textEscaped}':fontsize=${fontSize}:fontcolor=${fontColor}:box=1:boxcolor=${bgColor}:${positionStr}${animationFilter}:enable='between(t,${options.startTime},${options.endTime})'`;
|
|
1818
|
+
const command = `ffmpeg -i "${escapedVideoPath}" -vf "${drawtextFilter}" -y "${escapedOutputPath}"`;
|
|
1819
|
+
try {
|
|
1820
|
+
await this.executeFFmpegWithProgress(command, { timeout: 300000, maxBuffer: 10 * 1024 * 1024 }, onProgress);
|
|
1821
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1822
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1823
|
+
}
|
|
1824
|
+
return { outputPath: options.outputPath, success: true };
|
|
1825
|
+
}
|
|
1826
|
+
catch (error) {
|
|
1827
|
+
if (shouldCleanupVideo && fs_1.default.existsSync(videoPath)) {
|
|
1828
|
+
fs_1.default.unlinkSync(videoPath);
|
|
1829
|
+
}
|
|
1830
|
+
throw error;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
exports.VideoHelpers = VideoHelpers;
|
|
1835
|
+
//# sourceMappingURL=videoHelpers.js.map
|