orator-conversion 1.0.2 → 1.0.3
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/package.json +5 -5
- package/source/Conversion-Core.js +938 -0
- package/source/Orator-Conversion-BeaconProvider.js +726 -0
- package/source/Orator-File-Translation.js +9 -0
- package/source/endpoints/Endpoint-Image-Convert.js +33 -0
- package/source/endpoints/Endpoint-Image-Resize.js +49 -0
- package/source/endpoints/Endpoint-Image-Rotate.js +34 -0
- package/test/Conversion-Core_tests.js +442 -0
- package/test/Orator-File-Translation_basic_tests.js +10 -4
- package/test/Orator-File-Translation_tests.js +148 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversion-Core — Standalone conversion logic for images, video, and audio
|
|
3
|
+
*
|
|
4
|
+
* No Fable dependency. This module provides the raw conversion functions
|
|
5
|
+
* used by both the Orator HTTP endpoints and the Ultravisor beacon provider.
|
|
6
|
+
*
|
|
7
|
+
* Capabilities:
|
|
8
|
+
* - Image format conversion (JPG↔PNG, any-to-any) via Sharp
|
|
9
|
+
* - Image resize, rotate, flip/flop via Sharp
|
|
10
|
+
* - PDF page extraction via pdftk
|
|
11
|
+
* - PDF page rendering via pdftoppm + optional Sharp resize
|
|
12
|
+
* - Media metadata probing via ffprobe
|
|
13
|
+
* - Video frame extraction via ffmpeg
|
|
14
|
+
* - Audio segment extraction and waveform generation via ffmpeg
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const libSharp = require('sharp');
|
|
18
|
+
const libChildProcess = require('child_process');
|
|
19
|
+
const libFS = require('fs');
|
|
20
|
+
const libPath = require('path');
|
|
21
|
+
const libOS = require('os');
|
|
22
|
+
|
|
23
|
+
const _DEFAULT_PDFTK_PATH = 'pdftk';
|
|
24
|
+
const _DEFAULT_PDFTOPPM_PATH = 'pdftoppm';
|
|
25
|
+
const _DEFAULT_FFMPEG_PATH = 'ffmpeg';
|
|
26
|
+
const _DEFAULT_FFPROBE_PATH = 'ffprobe';
|
|
27
|
+
const _DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
28
|
+
|
|
29
|
+
class ConversionCore
|
|
30
|
+
{
|
|
31
|
+
constructor(pOptions)
|
|
32
|
+
{
|
|
33
|
+
let tmpOptions = pOptions || {};
|
|
34
|
+
|
|
35
|
+
this.PdftkPath = tmpOptions.PdftkPath || _DEFAULT_PDFTK_PATH;
|
|
36
|
+
this.PdftoppmPath = tmpOptions.PdftoppmPath || _DEFAULT_PDFTOPPM_PATH;
|
|
37
|
+
this.FfmpegPath = tmpOptions.FfmpegPath || _DEFAULT_FFMPEG_PATH;
|
|
38
|
+
this.FfprobePath = tmpOptions.FfprobePath || _DEFAULT_FFPROBE_PATH;
|
|
39
|
+
this.MaxFileSize = tmpOptions.MaxFileSize || _DEFAULT_MAX_FILE_SIZE;
|
|
40
|
+
|
|
41
|
+
// Optional log function — if not provided, log to console
|
|
42
|
+
this._log = tmpOptions.log || console.log;
|
|
43
|
+
this.LogLevel = tmpOptions.LogLevel || 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ================================================================
|
|
47
|
+
// Image Format Conversions
|
|
48
|
+
// ================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert a JPEG buffer to PNG.
|
|
52
|
+
*
|
|
53
|
+
* @param {Buffer} pInputBuffer - The JPEG image data.
|
|
54
|
+
* @param {Function} fCallback - Called with (pError, pOutputBuffer, pContentType).
|
|
55
|
+
*/
|
|
56
|
+
jpgToPng(pInputBuffer, fCallback)
|
|
57
|
+
{
|
|
58
|
+
libSharp(pInputBuffer)
|
|
59
|
+
.png()
|
|
60
|
+
.toBuffer()
|
|
61
|
+
.then(
|
|
62
|
+
(pOutputBuffer) =>
|
|
63
|
+
{
|
|
64
|
+
return fCallback(null, pOutputBuffer, 'image/png');
|
|
65
|
+
})
|
|
66
|
+
.catch(
|
|
67
|
+
(pError) =>
|
|
68
|
+
{
|
|
69
|
+
return fCallback(pError);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert a PNG buffer to JPEG.
|
|
75
|
+
*
|
|
76
|
+
* @param {Buffer} pInputBuffer - The PNG image data.
|
|
77
|
+
* @param {Function} fCallback - Called with (pError, pOutputBuffer, pContentType).
|
|
78
|
+
*/
|
|
79
|
+
pngToJpg(pInputBuffer, fCallback)
|
|
80
|
+
{
|
|
81
|
+
libSharp(pInputBuffer)
|
|
82
|
+
.jpeg()
|
|
83
|
+
.toBuffer()
|
|
84
|
+
.then(
|
|
85
|
+
(pOutputBuffer) =>
|
|
86
|
+
{
|
|
87
|
+
return fCallback(null, pOutputBuffer, 'image/jpeg');
|
|
88
|
+
})
|
|
89
|
+
.catch(
|
|
90
|
+
(pError) =>
|
|
91
|
+
{
|
|
92
|
+
return fCallback(pError);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resize an image buffer with Sharp.
|
|
98
|
+
*
|
|
99
|
+
* @param {Buffer} pInputBuffer - The image data (any format Sharp can read).
|
|
100
|
+
* @param {object} pOptions - Resize options.
|
|
101
|
+
* @param {number} [pOptions.Width] - Target width in pixels.
|
|
102
|
+
* @param {number} [pOptions.Height] - Target height in pixels.
|
|
103
|
+
* @param {string} [pOptions.Format] - Output format: 'png', 'jpeg', 'webp' (default: 'jpeg').
|
|
104
|
+
* @param {number} [pOptions.Quality] - Quality for lossy formats (1-100, default: 80).
|
|
105
|
+
* @param {boolean} [pOptions.AutoOrient] - Auto-orient from EXIF (default: true).
|
|
106
|
+
* @param {string} [pOptions.Fit] - Resize fit mode: 'cover', 'contain', 'fill', 'inside', 'outside'.
|
|
107
|
+
* @param {string} [pOptions.Position] - Resize position/gravity: 'centre', 'north', 'south', etc.
|
|
108
|
+
* @param {Function} fCallback - Called with (pError, pOutputBuffer, pContentType).
|
|
109
|
+
*/
|
|
110
|
+
imageResize(pInputBuffer, pOptions, fCallback)
|
|
111
|
+
{
|
|
112
|
+
let tmpOptions = pOptions || {};
|
|
113
|
+
let tmpFormat = (tmpOptions.Format || 'jpeg').toLowerCase();
|
|
114
|
+
let tmpQuality = tmpOptions.Quality || 80;
|
|
115
|
+
let tmpAutoOrient = (tmpOptions.AutoOrient !== false);
|
|
116
|
+
|
|
117
|
+
let tmpResizeConfig = {};
|
|
118
|
+
if (tmpOptions.Width)
|
|
119
|
+
{
|
|
120
|
+
tmpResizeConfig.width = parseInt(tmpOptions.Width, 10);
|
|
121
|
+
}
|
|
122
|
+
if (tmpOptions.Height)
|
|
123
|
+
{
|
|
124
|
+
tmpResizeConfig.height = parseInt(tmpOptions.Height, 10);
|
|
125
|
+
}
|
|
126
|
+
if (tmpOptions.Fit)
|
|
127
|
+
{
|
|
128
|
+
tmpResizeConfig.fit = tmpOptions.Fit;
|
|
129
|
+
}
|
|
130
|
+
if (tmpOptions.Position)
|
|
131
|
+
{
|
|
132
|
+
tmpResizeConfig.position = tmpOptions.Position;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let tmpSharpInstance = libSharp(pInputBuffer);
|
|
136
|
+
|
|
137
|
+
if (tmpAutoOrient)
|
|
138
|
+
{
|
|
139
|
+
tmpSharpInstance = tmpSharpInstance.rotate();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tmpResizeConfig.width || tmpResizeConfig.height)
|
|
143
|
+
{
|
|
144
|
+
tmpSharpInstance = tmpSharpInstance.resize(tmpResizeConfig);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let tmpContentType;
|
|
148
|
+
if (tmpFormat === 'png')
|
|
149
|
+
{
|
|
150
|
+
tmpSharpInstance = tmpSharpInstance.png();
|
|
151
|
+
tmpContentType = 'image/png';
|
|
152
|
+
}
|
|
153
|
+
else if (tmpFormat === 'webp')
|
|
154
|
+
{
|
|
155
|
+
tmpSharpInstance = tmpSharpInstance.webp({ quality: tmpQuality });
|
|
156
|
+
tmpContentType = 'image/webp';
|
|
157
|
+
}
|
|
158
|
+
else if (tmpFormat === 'avif')
|
|
159
|
+
{
|
|
160
|
+
tmpSharpInstance = tmpSharpInstance.avif({ quality: tmpQuality });
|
|
161
|
+
tmpContentType = 'image/avif';
|
|
162
|
+
}
|
|
163
|
+
else if (tmpFormat === 'tiff')
|
|
164
|
+
{
|
|
165
|
+
tmpSharpInstance = tmpSharpInstance.tiff({ quality: tmpQuality });
|
|
166
|
+
tmpContentType = 'image/tiff';
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
{
|
|
170
|
+
tmpSharpInstance = tmpSharpInstance.jpeg({ quality: tmpQuality });
|
|
171
|
+
tmpContentType = 'image/jpeg';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
tmpSharpInstance.toBuffer()
|
|
175
|
+
.then(
|
|
176
|
+
(pOutputBuffer) =>
|
|
177
|
+
{
|
|
178
|
+
return fCallback(null, pOutputBuffer, tmpContentType);
|
|
179
|
+
})
|
|
180
|
+
.catch(
|
|
181
|
+
(pError) =>
|
|
182
|
+
{
|
|
183
|
+
return fCallback(pError);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Rotate and/or flip an image buffer with Sharp.
|
|
189
|
+
*
|
|
190
|
+
* @param {Buffer} pInputBuffer - The image data (any format Sharp can read).
|
|
191
|
+
* @param {object} pOptions - Rotation options.
|
|
192
|
+
* @param {number} [pOptions.Angle] - Rotation angle in degrees (0, 90, 180, 270, or arbitrary).
|
|
193
|
+
* @param {boolean} [pOptions.Flip] - Flip vertically.
|
|
194
|
+
* @param {boolean} [pOptions.Flop] - Flip horizontally.
|
|
195
|
+
* @param {Function} fCallback - Called with (pError, pOutputBuffer, pContentType).
|
|
196
|
+
*/
|
|
197
|
+
imageRotate(pInputBuffer, pOptions, fCallback)
|
|
198
|
+
{
|
|
199
|
+
let tmpOptions = pOptions || {};
|
|
200
|
+
|
|
201
|
+
let tmpSharpInstance = libSharp(pInputBuffer);
|
|
202
|
+
|
|
203
|
+
if (typeof tmpOptions.Angle === 'number' || typeof tmpOptions.Angle === 'string')
|
|
204
|
+
{
|
|
205
|
+
tmpSharpInstance = tmpSharpInstance.rotate(parseInt(tmpOptions.Angle, 10));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (tmpOptions.Flip)
|
|
209
|
+
{
|
|
210
|
+
tmpSharpInstance = tmpSharpInstance.flip();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (tmpOptions.Flop)
|
|
214
|
+
{
|
|
215
|
+
tmpSharpInstance = tmpSharpInstance.flop();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
tmpSharpInstance.toBuffer({ resolveWithObject: true })
|
|
219
|
+
.then(
|
|
220
|
+
(pResult) =>
|
|
221
|
+
{
|
|
222
|
+
let tmpContentType = 'application/octet-stream';
|
|
223
|
+
let tmpFormat = pResult.info.format;
|
|
224
|
+
if (tmpFormat === 'jpeg')
|
|
225
|
+
{
|
|
226
|
+
tmpContentType = 'image/jpeg';
|
|
227
|
+
}
|
|
228
|
+
else if (tmpFormat === 'png')
|
|
229
|
+
{
|
|
230
|
+
tmpContentType = 'image/png';
|
|
231
|
+
}
|
|
232
|
+
else if (tmpFormat === 'webp')
|
|
233
|
+
{
|
|
234
|
+
tmpContentType = 'image/webp';
|
|
235
|
+
}
|
|
236
|
+
else if (tmpFormat === 'avif')
|
|
237
|
+
{
|
|
238
|
+
tmpContentType = 'image/avif';
|
|
239
|
+
}
|
|
240
|
+
else if (tmpFormat === 'tiff')
|
|
241
|
+
{
|
|
242
|
+
tmpContentType = 'image/tiff';
|
|
243
|
+
}
|
|
244
|
+
return fCallback(null, pResult.data, tmpContentType);
|
|
245
|
+
})
|
|
246
|
+
.catch(
|
|
247
|
+
(pError) =>
|
|
248
|
+
{
|
|
249
|
+
return fCallback(pError);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Convert an image buffer to a different format via Sharp.
|
|
255
|
+
*
|
|
256
|
+
* @param {Buffer} pInputBuffer - The image data (any format Sharp can read).
|
|
257
|
+
* @param {object} pOptions - Conversion options.
|
|
258
|
+
* @param {string} pOptions.Format - Target format: 'jpeg', 'png', 'webp', 'avif', 'tiff'.
|
|
259
|
+
* @param {number} [pOptions.Quality] - Quality for lossy formats (1-100, default: 80).
|
|
260
|
+
* @param {boolean} [pOptions.AutoOrient] - Auto-orient from EXIF (default: true).
|
|
261
|
+
* @param {Function} fCallback - Called with (pError, pOutputBuffer, pContentType).
|
|
262
|
+
*/
|
|
263
|
+
imageConvert(pInputBuffer, pOptions, fCallback)
|
|
264
|
+
{
|
|
265
|
+
let tmpOptions = pOptions || {};
|
|
266
|
+
let tmpFormat = (tmpOptions.Format || 'jpeg').toLowerCase();
|
|
267
|
+
let tmpQuality = tmpOptions.Quality || 80;
|
|
268
|
+
let tmpAutoOrient = (tmpOptions.AutoOrient !== false);
|
|
269
|
+
|
|
270
|
+
let tmpSharpInstance = libSharp(pInputBuffer);
|
|
271
|
+
|
|
272
|
+
if (tmpAutoOrient)
|
|
273
|
+
{
|
|
274
|
+
tmpSharpInstance = tmpSharpInstance.rotate();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let tmpContentType;
|
|
278
|
+
switch (tmpFormat)
|
|
279
|
+
{
|
|
280
|
+
case 'png':
|
|
281
|
+
tmpSharpInstance = tmpSharpInstance.png();
|
|
282
|
+
tmpContentType = 'image/png';
|
|
283
|
+
break;
|
|
284
|
+
case 'webp':
|
|
285
|
+
tmpSharpInstance = tmpSharpInstance.webp({ quality: tmpQuality });
|
|
286
|
+
tmpContentType = 'image/webp';
|
|
287
|
+
break;
|
|
288
|
+
case 'avif':
|
|
289
|
+
tmpSharpInstance = tmpSharpInstance.avif({ quality: tmpQuality });
|
|
290
|
+
tmpContentType = 'image/avif';
|
|
291
|
+
break;
|
|
292
|
+
case 'tiff':
|
|
293
|
+
tmpSharpInstance = tmpSharpInstance.tiff({ quality: tmpQuality });
|
|
294
|
+
tmpContentType = 'image/tiff';
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
tmpSharpInstance = tmpSharpInstance.jpeg({ quality: tmpQuality });
|
|
298
|
+
tmpContentType = 'image/jpeg';
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
tmpSharpInstance.toBuffer()
|
|
303
|
+
.then(
|
|
304
|
+
(pOutputBuffer) =>
|
|
305
|
+
{
|
|
306
|
+
return fCallback(null, pOutputBuffer, tmpContentType);
|
|
307
|
+
})
|
|
308
|
+
.catch(
|
|
309
|
+
(pError) =>
|
|
310
|
+
{
|
|
311
|
+
return fCallback(pError);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ================================================================
|
|
316
|
+
// PDF Operations
|
|
317
|
+
// ================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Extract a single page from a PDF buffer using pdftk.
|
|
321
|
+
*
|
|
322
|
+
* @param {Buffer} pPdfBuffer - The input PDF buffer.
|
|
323
|
+
* @param {number} pPageNumber - The 1-based page number to extract.
|
|
324
|
+
* @param {Function} fCallback - Called with (pError, pSinglePagePdfBuffer).
|
|
325
|
+
*/
|
|
326
|
+
extractPdfPage(pPdfBuffer, pPageNumber, fCallback)
|
|
327
|
+
{
|
|
328
|
+
let tmpTempDir = libOS.tmpdir();
|
|
329
|
+
let tmpUniqueId = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
330
|
+
let tmpInputPath = libPath.join(tmpTempDir, `orator_ft_input_${tmpUniqueId}.pdf`);
|
|
331
|
+
let tmpOutputPath = libPath.join(tmpTempDir, `orator_ft_output_${tmpUniqueId}.pdf`);
|
|
332
|
+
|
|
333
|
+
let tmpCleanup = () =>
|
|
334
|
+
{
|
|
335
|
+
try { libFS.unlinkSync(tmpInputPath); } catch (pIgnore) { /* file may not exist */ }
|
|
336
|
+
try { libFS.unlinkSync(tmpOutputPath); } catch (pIgnore) { /* file may not exist */ }
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
try
|
|
340
|
+
{
|
|
341
|
+
libFS.writeFileSync(tmpInputPath, pPdfBuffer);
|
|
342
|
+
}
|
|
343
|
+
catch (pWriteError)
|
|
344
|
+
{
|
|
345
|
+
tmpCleanup();
|
|
346
|
+
return fCallback(new Error(`Failed to write temporary PDF file: ${pWriteError.message}`));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let tmpCommand = `${this.PdftkPath} "${tmpInputPath}" cat ${pPageNumber} output "${tmpOutputPath}"`;
|
|
350
|
+
|
|
351
|
+
if (this.LogLevel > 1)
|
|
352
|
+
{
|
|
353
|
+
this._log(`ConversionCore: executing pdftk command: ${tmpCommand}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
libChildProcess.exec(tmpCommand,
|
|
357
|
+
{
|
|
358
|
+
timeout: 30000,
|
|
359
|
+
maxBuffer: this.MaxFileSize
|
|
360
|
+
},
|
|
361
|
+
(pExecError, pStdout, pStderr) =>
|
|
362
|
+
{
|
|
363
|
+
if (pExecError)
|
|
364
|
+
{
|
|
365
|
+
tmpCleanup();
|
|
366
|
+
let tmpMessage = pStderr ? pStderr.toString().trim() : pExecError.message;
|
|
367
|
+
return fCallback(new Error(`pdftk failed: ${tmpMessage}`));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try
|
|
371
|
+
{
|
|
372
|
+
let tmpOutputBuffer = libFS.readFileSync(tmpOutputPath);
|
|
373
|
+
tmpCleanup();
|
|
374
|
+
return fCallback(null, tmpOutputBuffer);
|
|
375
|
+
}
|
|
376
|
+
catch (pReadError)
|
|
377
|
+
{
|
|
378
|
+
tmpCleanup();
|
|
379
|
+
return fCallback(new Error(`Failed to read pdftk output: ${pReadError.message}`));
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Render a specific page of a PDF to an image buffer.
|
|
386
|
+
*
|
|
387
|
+
* Uses pdftoppm to rasterize the page. When pOptions.LongSidePixels is
|
|
388
|
+
* provided, the image is rendered at 300 DPI then resized.
|
|
389
|
+
*
|
|
390
|
+
* @param {Buffer} pPdfBuffer - The input PDF buffer.
|
|
391
|
+
* @param {number} pPageNumber - The 1-based page number to render.
|
|
392
|
+
* @param {string} pFormat - Output format: 'png' or 'jpeg'.
|
|
393
|
+
* @param {Function} fCallback - Called with (pError, pImageBuffer).
|
|
394
|
+
* @param {object} [pOptions] - Optional rendering options.
|
|
395
|
+
* @param {number} [pOptions.LongSidePixels] - Target size for the longest side.
|
|
396
|
+
*/
|
|
397
|
+
renderPdfPageToImage(pPdfBuffer, pPageNumber, pFormat, fCallback, pOptions)
|
|
398
|
+
{
|
|
399
|
+
let tmpTempDir = libOS.tmpdir();
|
|
400
|
+
let tmpUniqueId = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
401
|
+
let tmpInputPath = libPath.join(tmpTempDir, `orator_ft_render_input_${tmpUniqueId}.pdf`);
|
|
402
|
+
let tmpOutputPrefix = libPath.join(tmpTempDir, `orator_ft_render_output_${tmpUniqueId}`);
|
|
403
|
+
|
|
404
|
+
let tmpLongSidePixels = (pOptions && pOptions.LongSidePixels) ? pOptions.LongSidePixels : 0;
|
|
405
|
+
|
|
406
|
+
// When resizing is requested, render at 300 DPI for higher quality source material
|
|
407
|
+
let tmpRenderDpi = (tmpLongSidePixels > 0) ? 300 : 150;
|
|
408
|
+
|
|
409
|
+
// pdftoppm appends -NNNNNN.png (or .jpg) to the output prefix
|
|
410
|
+
let tmpExpectedSuffix = (pFormat === 'jpeg') ? '.jpg' : '.png';
|
|
411
|
+
|
|
412
|
+
let tmpCleanup = () =>
|
|
413
|
+
{
|
|
414
|
+
try { libFS.unlinkSync(tmpInputPath); } catch (pIgnore) { /* file may not exist */ }
|
|
415
|
+
try
|
|
416
|
+
{
|
|
417
|
+
let tmpDirContents = libFS.readdirSync(tmpTempDir);
|
|
418
|
+
let tmpPrefix = libPath.basename(tmpOutputPrefix);
|
|
419
|
+
for (let i = 0; i < tmpDirContents.length; i++)
|
|
420
|
+
{
|
|
421
|
+
if (tmpDirContents[i].startsWith(tmpPrefix))
|
|
422
|
+
{
|
|
423
|
+
try { libFS.unlinkSync(libPath.join(tmpTempDir, tmpDirContents[i])); } catch (pIgnore) { /* ignore */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (pIgnore) { /* ignore readdir errors */ }
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
try
|
|
431
|
+
{
|
|
432
|
+
libFS.writeFileSync(tmpInputPath, pPdfBuffer);
|
|
433
|
+
}
|
|
434
|
+
catch (pWriteError)
|
|
435
|
+
{
|
|
436
|
+
tmpCleanup();
|
|
437
|
+
return fCallback(new Error(`Failed to write temporary PDF file: ${pWriteError.message}`));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let tmpFormatFlag = (pFormat === 'jpeg') ? '-jpeg' : '-png';
|
|
441
|
+
let tmpCommand = `${this.PdftoppmPath} ${tmpFormatFlag} -f ${pPageNumber} -l ${pPageNumber} -r ${tmpRenderDpi} "${tmpInputPath}" "${tmpOutputPrefix}"`;
|
|
442
|
+
|
|
443
|
+
if (this.LogLevel > 1)
|
|
444
|
+
{
|
|
445
|
+
this._log(`ConversionCore: executing pdftoppm command: ${tmpCommand}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
libChildProcess.exec(tmpCommand,
|
|
449
|
+
{
|
|
450
|
+
timeout: 30000,
|
|
451
|
+
maxBuffer: this.MaxFileSize
|
|
452
|
+
},
|
|
453
|
+
(pExecError, pStdout, pStderr) =>
|
|
454
|
+
{
|
|
455
|
+
if (pExecError)
|
|
456
|
+
{
|
|
457
|
+
tmpCleanup();
|
|
458
|
+
let tmpMessage = pStderr ? pStderr.toString().trim() : pExecError.message;
|
|
459
|
+
return fCallback(new Error(`pdftoppm failed: ${tmpMessage}`));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try
|
|
463
|
+
{
|
|
464
|
+
let tmpDirContents = libFS.readdirSync(tmpTempDir);
|
|
465
|
+
let tmpPrefix = libPath.basename(tmpOutputPrefix);
|
|
466
|
+
let tmpOutputFile = null;
|
|
467
|
+
|
|
468
|
+
for (let i = 0; i < tmpDirContents.length; i++)
|
|
469
|
+
{
|
|
470
|
+
if (tmpDirContents[i].startsWith(tmpPrefix) && tmpDirContents[i].endsWith(tmpExpectedSuffix))
|
|
471
|
+
{
|
|
472
|
+
tmpOutputFile = libPath.join(tmpTempDir, tmpDirContents[i]);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!tmpOutputFile)
|
|
478
|
+
{
|
|
479
|
+
tmpCleanup();
|
|
480
|
+
return fCallback(new Error('pdftoppm produced no output file.'));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let tmpImageBuffer = libFS.readFileSync(tmpOutputFile);
|
|
484
|
+
tmpCleanup();
|
|
485
|
+
|
|
486
|
+
if (tmpLongSidePixels > 0)
|
|
487
|
+
{
|
|
488
|
+
let tmpSharpInstance = libSharp(tmpImageBuffer);
|
|
489
|
+
|
|
490
|
+
tmpSharpInstance.metadata()
|
|
491
|
+
.then(
|
|
492
|
+
(pMetadata) =>
|
|
493
|
+
{
|
|
494
|
+
let tmpResizeOptions = {};
|
|
495
|
+
if (pMetadata.width >= pMetadata.height)
|
|
496
|
+
{
|
|
497
|
+
tmpResizeOptions.width = tmpLongSidePixels;
|
|
498
|
+
}
|
|
499
|
+
else
|
|
500
|
+
{
|
|
501
|
+
tmpResizeOptions.height = tmpLongSidePixels;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let tmpResizer = tmpSharpInstance.resize(tmpResizeOptions);
|
|
505
|
+
|
|
506
|
+
if (pFormat === 'jpeg')
|
|
507
|
+
{
|
|
508
|
+
tmpResizer = tmpResizer.jpeg();
|
|
509
|
+
}
|
|
510
|
+
else
|
|
511
|
+
{
|
|
512
|
+
tmpResizer = tmpResizer.png();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return tmpResizer.toBuffer();
|
|
516
|
+
})
|
|
517
|
+
.then(
|
|
518
|
+
(pResizedBuffer) =>
|
|
519
|
+
{
|
|
520
|
+
return fCallback(null, pResizedBuffer);
|
|
521
|
+
})
|
|
522
|
+
.catch(
|
|
523
|
+
(pResizeError) =>
|
|
524
|
+
{
|
|
525
|
+
return fCallback(new Error(`Image resize failed: ${pResizeError.message}`));
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
else
|
|
529
|
+
{
|
|
530
|
+
return fCallback(null, tmpImageBuffer);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (pReadError)
|
|
534
|
+
{
|
|
535
|
+
tmpCleanup();
|
|
536
|
+
return fCallback(new Error(`Failed to read pdftoppm output: ${pReadError.message}`));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ================================================================
|
|
542
|
+
// Tool Availability Checks
|
|
543
|
+
// ================================================================
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Check if Sharp is available and functional.
|
|
547
|
+
*
|
|
548
|
+
* @param {Function} fCallback - Called with (pError, pAvailable).
|
|
549
|
+
*/
|
|
550
|
+
checkSharp(fCallback)
|
|
551
|
+
{
|
|
552
|
+
try
|
|
553
|
+
{
|
|
554
|
+
// Create a minimal test image to verify Sharp works
|
|
555
|
+
libSharp({
|
|
556
|
+
create: {
|
|
557
|
+
width: 1, height: 1,
|
|
558
|
+
channels: 3,
|
|
559
|
+
background: { r: 0, g: 0, b: 0 }
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
.png()
|
|
563
|
+
.toBuffer()
|
|
564
|
+
.then(() =>
|
|
565
|
+
{
|
|
566
|
+
return fCallback(null, true);
|
|
567
|
+
})
|
|
568
|
+
.catch((pError) =>
|
|
569
|
+
{
|
|
570
|
+
return fCallback(new Error(`Sharp not functional: ${pError.message}`), false);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (pError)
|
|
574
|
+
{
|
|
575
|
+
return fCallback(new Error(`Sharp not available: ${pError.message}`), false);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Check if pdftoppm is available.
|
|
581
|
+
*
|
|
582
|
+
* @param {Function} fCallback - Called with (pError, pAvailable).
|
|
583
|
+
*/
|
|
584
|
+
checkPdftoppm(fCallback)
|
|
585
|
+
{
|
|
586
|
+
libChildProcess.exec(`${this.PdftoppmPath} -v`,
|
|
587
|
+
{ timeout: 5000 },
|
|
588
|
+
(pError, pStdout, pStderr) =>
|
|
589
|
+
{
|
|
590
|
+
// pdftoppm -v outputs version to stderr
|
|
591
|
+
if (pError && !pStderr)
|
|
592
|
+
{
|
|
593
|
+
return fCallback(new Error(`pdftoppm not available: ${pError.message}`), false);
|
|
594
|
+
}
|
|
595
|
+
return fCallback(null, true);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Check if pdftk is available.
|
|
601
|
+
*
|
|
602
|
+
* @param {Function} fCallback - Called with (pError, pAvailable).
|
|
603
|
+
*/
|
|
604
|
+
checkPdftk(fCallback)
|
|
605
|
+
{
|
|
606
|
+
libChildProcess.exec(`${this.PdftkPath} --version`,
|
|
607
|
+
{ timeout: 5000 },
|
|
608
|
+
(pError, pStdout, pStderr) =>
|
|
609
|
+
{
|
|
610
|
+
if (pError)
|
|
611
|
+
{
|
|
612
|
+
return fCallback(new Error(`pdftk not available: ${pError.message}`), false);
|
|
613
|
+
}
|
|
614
|
+
return fCallback(null, true);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Check if ffmpeg is available.
|
|
620
|
+
*
|
|
621
|
+
* @param {Function} fCallback - Called with (pError, pAvailable).
|
|
622
|
+
*/
|
|
623
|
+
checkFfmpeg(fCallback)
|
|
624
|
+
{
|
|
625
|
+
libChildProcess.exec(`${this.FfmpegPath} -version`,
|
|
626
|
+
{ timeout: 5000 },
|
|
627
|
+
(pError, pStdout, pStderr) =>
|
|
628
|
+
{
|
|
629
|
+
if (pError)
|
|
630
|
+
{
|
|
631
|
+
return fCallback(new Error(`ffmpeg not available: ${pError.message}`), false);
|
|
632
|
+
}
|
|
633
|
+
return fCallback(null, true);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Check if ffprobe is available.
|
|
639
|
+
*
|
|
640
|
+
* @param {Function} fCallback - Called with (pError, pAvailable).
|
|
641
|
+
*/
|
|
642
|
+
checkFfprobe(fCallback)
|
|
643
|
+
{
|
|
644
|
+
libChildProcess.exec(`${this.FfprobePath} -version`,
|
|
645
|
+
{ timeout: 5000 },
|
|
646
|
+
(pError, pStdout, pStderr) =>
|
|
647
|
+
{
|
|
648
|
+
if (pError)
|
|
649
|
+
{
|
|
650
|
+
return fCallback(new Error(`ffprobe not available: ${pError.message}`), false);
|
|
651
|
+
}
|
|
652
|
+
return fCallback(null, true);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ================================================================
|
|
657
|
+
// Media Probing (ffprobe)
|
|
658
|
+
// ================================================================
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Extract media metadata using ffprobe.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} pFilePath - Path to the media file.
|
|
664
|
+
* @param {Function} fCallback - Called with (pError, pMetadataObject).
|
|
665
|
+
*/
|
|
666
|
+
mediaProbe(pFilePath, fCallback)
|
|
667
|
+
{
|
|
668
|
+
if (!pFilePath)
|
|
669
|
+
{
|
|
670
|
+
return fCallback(new Error('No file path provided for mediaProbe.'));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let tmpCommand = `${this.FfprobePath} -v quiet -print_format json -show_format -show_streams "${pFilePath}"`;
|
|
674
|
+
|
|
675
|
+
if (this.LogLevel > 1)
|
|
676
|
+
{
|
|
677
|
+
this._log(`ConversionCore: executing ffprobe command: ${tmpCommand}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
libChildProcess.exec(tmpCommand,
|
|
681
|
+
{
|
|
682
|
+
timeout: 30000,
|
|
683
|
+
maxBuffer: this.MaxFileSize
|
|
684
|
+
},
|
|
685
|
+
(pExecError, pStdout, pStderr) =>
|
|
686
|
+
{
|
|
687
|
+
if (pExecError)
|
|
688
|
+
{
|
|
689
|
+
let tmpMessage = pStderr ? pStderr.toString().trim() : pExecError.message;
|
|
690
|
+
return fCallback(new Error(`ffprobe failed: ${tmpMessage}`));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
try
|
|
694
|
+
{
|
|
695
|
+
let tmpMetadata = JSON.parse(pStdout.toString());
|
|
696
|
+
return fCallback(null, tmpMetadata);
|
|
697
|
+
}
|
|
698
|
+
catch (pParseError)
|
|
699
|
+
{
|
|
700
|
+
return fCallback(new Error(`Failed to parse ffprobe output: ${pParseError.message}`));
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ================================================================
|
|
706
|
+
// Video Operations (ffmpeg)
|
|
707
|
+
// ================================================================
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Extract a single frame from a video file at a given timestamp.
|
|
711
|
+
*
|
|
712
|
+
* @param {string} pInputPath - Path to the input video file.
|
|
713
|
+
* @param {string} pOutputPath - Path for the output JPEG image.
|
|
714
|
+
* @param {object} pOptions - Extraction options.
|
|
715
|
+
* @param {string} [pOptions.Timestamp] - Seek position (default: '00:00:00').
|
|
716
|
+
* @param {number} [pOptions.Width] - Scale width (height auto-calculated).
|
|
717
|
+
* @param {number} [pOptions.Height] - Scale height (width auto-calculated).
|
|
718
|
+
* @param {Function} fCallback - Called with (pError, pOutputPath).
|
|
719
|
+
*/
|
|
720
|
+
videoExtractFrame(pInputPath, pOutputPath, pOptions, fCallback)
|
|
721
|
+
{
|
|
722
|
+
let tmpOptions = pOptions || {};
|
|
723
|
+
let tmpTimestamp = tmpOptions.Timestamp || '00:00:00';
|
|
724
|
+
|
|
725
|
+
let tmpScaleFilter = '';
|
|
726
|
+
if (tmpOptions.Width || tmpOptions.Height)
|
|
727
|
+
{
|
|
728
|
+
let tmpW = tmpOptions.Width ? parseInt(tmpOptions.Width, 10) : -1;
|
|
729
|
+
let tmpH = tmpOptions.Height ? parseInt(tmpOptions.Height, 10) : -1;
|
|
730
|
+
tmpScaleFilter = ` -vf "scale=${tmpW}:${tmpH}:force_original_aspect_ratio=decrease"`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Ensure output directory exists
|
|
734
|
+
let tmpOutputDir = libPath.dirname(pOutputPath);
|
|
735
|
+
if (!libFS.existsSync(tmpOutputDir))
|
|
736
|
+
{
|
|
737
|
+
libFS.mkdirSync(tmpOutputDir, { recursive: true });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let tmpCommand = `${this.FfmpegPath} -ss ${tmpTimestamp} -i "${pInputPath}" -vframes 1${tmpScaleFilter} -f mjpeg -y "${pOutputPath}"`;
|
|
741
|
+
|
|
742
|
+
if (this.LogLevel > 1)
|
|
743
|
+
{
|
|
744
|
+
this._log(`ConversionCore: executing ffmpeg command: ${tmpCommand}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
libChildProcess.exec(tmpCommand,
|
|
748
|
+
{
|
|
749
|
+
timeout: 60000,
|
|
750
|
+
maxBuffer: this.MaxFileSize
|
|
751
|
+
},
|
|
752
|
+
(pExecError, pStdout, pStderr) =>
|
|
753
|
+
{
|
|
754
|
+
if (pExecError)
|
|
755
|
+
{
|
|
756
|
+
let tmpMessage = pStderr ? pStderr.toString().trim() : pExecError.message;
|
|
757
|
+
return fCallback(new Error(`ffmpeg frame extraction failed: ${tmpMessage}`));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (!libFS.existsSync(pOutputPath))
|
|
761
|
+
{
|
|
762
|
+
return fCallback(new Error('ffmpeg produced no output file.'));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return fCallback(null, pOutputPath);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Generate a thumbnail from a video file.
|
|
771
|
+
*
|
|
772
|
+
* Convenience wrapper around videoExtractFrame with sensible defaults.
|
|
773
|
+
*
|
|
774
|
+
* @param {string} pInputPath - Path to the input video file.
|
|
775
|
+
* @param {string} pOutputPath - Path for the output JPEG thumbnail.
|
|
776
|
+
* @param {object} pOptions - Thumbnail options.
|
|
777
|
+
* @param {string} [pOptions.Timestamp] - Seek position (default: '00:00:01').
|
|
778
|
+
* @param {number} [pOptions.Width] - Thumbnail width (default: 320).
|
|
779
|
+
* @param {Function} fCallback - Called with (pError, pOutputPath).
|
|
780
|
+
*/
|
|
781
|
+
videoThumbnail(pInputPath, pOutputPath, pOptions, fCallback)
|
|
782
|
+
{
|
|
783
|
+
let tmpOptions = pOptions || {};
|
|
784
|
+
|
|
785
|
+
this.videoExtractFrame(pInputPath, pOutputPath,
|
|
786
|
+
{
|
|
787
|
+
Timestamp: tmpOptions.Timestamp || '00:00:01',
|
|
788
|
+
Width: tmpOptions.Width || 320
|
|
789
|
+
},
|
|
790
|
+
fCallback);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ================================================================
|
|
794
|
+
// Audio Operations (ffmpeg)
|
|
795
|
+
// ================================================================
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Extract a time-range segment from an audio or video file.
|
|
799
|
+
*
|
|
800
|
+
* @param {string} pInputPath - Path to the input file.
|
|
801
|
+
* @param {string} pOutputPath - Path for the output audio file.
|
|
802
|
+
* @param {object} pOptions - Extraction options.
|
|
803
|
+
* @param {string|number} [pOptions.Start] - Start time in seconds or HH:MM:SS (default: '0').
|
|
804
|
+
* @param {string|number} pOptions.Duration - Segment duration in seconds or HH:MM:SS.
|
|
805
|
+
* @param {string} [pOptions.Codec] - Output codec: 'mp3', 'wav', 'flac', 'ogg', 'aac' (default: 'mp3').
|
|
806
|
+
* @param {Function} fCallback - Called with (pError, pOutputPath).
|
|
807
|
+
*/
|
|
808
|
+
audioExtractSegment(pInputPath, pOutputPath, pOptions, fCallback)
|
|
809
|
+
{
|
|
810
|
+
let tmpOptions = pOptions || {};
|
|
811
|
+
let tmpStart = tmpOptions.Start || '0';
|
|
812
|
+
let tmpDuration = tmpOptions.Duration;
|
|
813
|
+
let tmpCodec = (tmpOptions.Codec || 'mp3').toLowerCase();
|
|
814
|
+
|
|
815
|
+
if (!tmpDuration)
|
|
816
|
+
{
|
|
817
|
+
return fCallback(new Error('Duration is required for audioExtractSegment.'));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
let tmpCodecArgs =
|
|
821
|
+
{
|
|
822
|
+
'mp3': '-c:a libmp3lame -q:a 2',
|
|
823
|
+
'wav': '-c:a pcm_s16le',
|
|
824
|
+
'flac': '-c:a flac',
|
|
825
|
+
'ogg': '-c:a libvorbis -q:a 5',
|
|
826
|
+
'aac': '-c:a aac -b:a 192k'
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
let tmpArgs = tmpCodecArgs[tmpCodec] || tmpCodecArgs['mp3'];
|
|
830
|
+
|
|
831
|
+
// Ensure output directory exists
|
|
832
|
+
let tmpOutputDir = libPath.dirname(pOutputPath);
|
|
833
|
+
if (!libFS.existsSync(tmpOutputDir))
|
|
834
|
+
{
|
|
835
|
+
libFS.mkdirSync(tmpOutputDir, { recursive: true });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let tmpCommand = `${this.FfmpegPath} -ss ${tmpStart} -t ${tmpDuration} -i "${pInputPath}" -vn ${tmpArgs} -y "${pOutputPath}"`;
|
|
839
|
+
|
|
840
|
+
if (this.LogLevel > 1)
|
|
841
|
+
{
|
|
842
|
+
this._log(`ConversionCore: executing ffmpeg command: ${tmpCommand}`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
libChildProcess.exec(tmpCommand,
|
|
846
|
+
{
|
|
847
|
+
timeout: 120000,
|
|
848
|
+
maxBuffer: this.MaxFileSize
|
|
849
|
+
},
|
|
850
|
+
(pExecError, pStdout, pStderr) =>
|
|
851
|
+
{
|
|
852
|
+
if (pExecError)
|
|
853
|
+
{
|
|
854
|
+
let tmpMessage = pStderr ? pStderr.toString().trim() : pExecError.message;
|
|
855
|
+
return fCallback(new Error(`ffmpeg audio extraction failed: ${tmpMessage}`));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!libFS.existsSync(pOutputPath))
|
|
859
|
+
{
|
|
860
|
+
return fCallback(new Error('ffmpeg produced no output file.'));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return fCallback(null, pOutputPath);
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Extract waveform peak data from an audio file via ffmpeg PCM pipe.
|
|
869
|
+
*
|
|
870
|
+
* Returns an array of normalized peak amplitudes (0.0 to 1.0).
|
|
871
|
+
*
|
|
872
|
+
* @param {string} pInputPath - Path to the input audio/video file.
|
|
873
|
+
* @param {object} pOptions - Waveform options.
|
|
874
|
+
* @param {number} [pOptions.SampleRate] - PCM sample rate (default: 8000).
|
|
875
|
+
* @param {number} [pOptions.Samples] - Number of peak values to return (default: 800).
|
|
876
|
+
* @param {Function} fCallback - Called with (pError, pPeaksArray).
|
|
877
|
+
*/
|
|
878
|
+
audioWaveform(pInputPath, pOptions, fCallback)
|
|
879
|
+
{
|
|
880
|
+
let tmpOptions = pOptions || {};
|
|
881
|
+
let tmpSampleRate = tmpOptions.SampleRate || 8000;
|
|
882
|
+
let tmpSamples = tmpOptions.Samples || 800;
|
|
883
|
+
|
|
884
|
+
let tmpCommand = `${this.FfmpegPath} -i "${pInputPath}" -ac 1 -ar ${tmpSampleRate} -f s16le -acodec pcm_s16le pipe:1`;
|
|
885
|
+
|
|
886
|
+
if (this.LogLevel > 1)
|
|
887
|
+
{
|
|
888
|
+
this._log(`ConversionCore: executing ffmpeg waveform command: ${tmpCommand}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
let tmpProcess = libChildProcess.exec(tmpCommand,
|
|
892
|
+
{
|
|
893
|
+
timeout: 60000,
|
|
894
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB for raw PCM
|
|
895
|
+
encoding: 'buffer'
|
|
896
|
+
},
|
|
897
|
+
(pExecError, pStdout, pStderr) =>
|
|
898
|
+
{
|
|
899
|
+
if (pExecError)
|
|
900
|
+
{
|
|
901
|
+
let tmpMessage = (pStderr && pStderr.length > 0) ? pStderr.toString().trim() : pExecError.message;
|
|
902
|
+
return fCallback(new Error(`ffmpeg waveform extraction failed: ${tmpMessage}`));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!pStdout || pStdout.length < 2)
|
|
906
|
+
{
|
|
907
|
+
return fCallback(new Error('ffmpeg produced no PCM output.'));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Process PCM s16le data into peak values
|
|
911
|
+
let tmpTotalSamples = Math.floor(pStdout.length / 2);
|
|
912
|
+
let tmpWindowSize = Math.max(1, Math.floor(tmpTotalSamples / tmpSamples));
|
|
913
|
+
let tmpPeaks = [];
|
|
914
|
+
|
|
915
|
+
for (let i = 0; i < tmpSamples; i++)
|
|
916
|
+
{
|
|
917
|
+
let tmpStart = i * tmpWindowSize;
|
|
918
|
+
let tmpEnd = Math.min(tmpStart + tmpWindowSize, tmpTotalSamples);
|
|
919
|
+
let tmpPeak = 0;
|
|
920
|
+
|
|
921
|
+
for (let j = tmpStart; j < tmpEnd; j++)
|
|
922
|
+
{
|
|
923
|
+
let tmpValue = Math.abs(pStdout.readInt16LE(j * 2));
|
|
924
|
+
if (tmpValue > tmpPeak)
|
|
925
|
+
{
|
|
926
|
+
tmpPeak = tmpValue;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
tmpPeaks.push(tmpPeak / 32768.0);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return fCallback(null, tmpPeaks);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
module.exports = ConversionCore;
|