forked from whyboris/Video-Hub-App
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain-extract.ts
561 lines (471 loc) · 18.4 KB
/
main-extract.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
/**
* This file contains all the logic for extracting:
* first thumbnail,
* full filmstrip,
* the preview clip
* the clip's first thumbnail
*
* All functions are PURE
* The only exception is `extractFromTheseFiles`
* which checks the global variable `globals.cancelCurrentImport`
* in case it needs to stop running (user interrupt)
*
* Huge thank you to cal2195 for the code contribution
* He implemented the efficient filmstrip and clip extraction!
*/
// ========================================================================================
// Imports
// ========================================================================================
// cool method to disable all console.log statements!
// console.log('console.log disabled in main-extract.ts');
// console.log = function() {};
// const { performance } = require('perf_hooks'); // for logging time taken during debug
const fs = require('fs');
import * as path from 'path';
const spawn = require('child_process').spawn;
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path.replace('app.asar', 'app.asar.unpacked');
import { globals, ScreenshotSettings } from './main-globals';
import { sendCurrentProgress } from './main-support';
import { ImageElement } from './interfaces/final-object.interface';
// ========================================================================================
// FFMPEG arg generating functions
// ========================================================================================
/**
* Generate the ffmpeg args to extract a single frame according to settings
* @param pathToVideo
* @param screenshotHeight
* @param duration
* @param savePath
*/
const extractSingleFrameArgs = (
pathToVideo: string,
screenshotHeight: number,
duration: number,
savePath: string,
): string[] => {
const ssWidth: number = screenshotHeight * (16 / 9);
const args: string[] = [
'-ss', (duration / 10).toString(),
'-i', pathToVideo,
'-frames', '1',
'-q:v', '2',
'-vf', scaleAndPadString(ssWidth, screenshotHeight),
savePath,
];
return args;
};
/**
* Take N screenshots of a particular file
* at particular file size
* save as particular fileHash
* (if filmstrip not already present)
*
* @param pathToVideo -- full path to the video file
* @param duration -- duration of clip
* @param screenshotHeight -- height of screenshot in pixels (defaul is 100)
* @param numberOfScreenshots -- number of screenshots to extract
* @param savePath -- full path to file name and extension
*/
const generateScreenshotStripArgs = (
pathToVideo: string,
duration: number,
screenshotHeight: number,
numberOfScreenshots: number,
savePath: string,
): string[] => {
let current = 0;
const totalCount = numberOfScreenshots;
const step: number = duration / (totalCount + 1);
const args: string[] = [];
let allFramesFiltered = '';
let outputFrames = '';
// Hardcode a specific 16:9 ratio
const ssWidth: number = screenshotHeight * (16 / 9);
const fancyScaleFilter: string = scaleAndPadString(ssWidth, screenshotHeight);
// make the magic filter
while (current < totalCount) {
const time = (current + 1) * step; // +1 so we don't pick the 0th frame
args.push('-ss', time.toString(), '-i', pathToVideo);
allFramesFiltered += '[' + current + ':V]' + fancyScaleFilter + '[' + current + '];';
outputFrames += '[' + current + ']';
current++;
}
args.push(
'-frames', '1',
'-filter_complex', allFramesFiltered + outputFrames + 'hstack=inputs=' + totalCount,
savePath
);
return args;
};
/**
* Generate the mp4 preview clip of the video file
* (if clip is not already present)
*
* @param pathToVideo -- full path to the video file
* @param duration -- duration of the original video file
* @param clipHeight -- height of clip
* @param clipSnippets -- number of clip snippets to extract
* @param snippetLength -- length in seconds of each snippet
* @param savePath -- full path to file name and extension
*/
const generatePreviewClipArgs = (
pathToVideo: string,
duration: number,
clipHeight: number,
clipSnippets: number,
snippetLength: number,
savePath: string,
): string[] => {
let current = 1;
const totalCount = clipSnippets;
const step: number = duration / totalCount;
const args: string[] = [];
let concat = '';
// make the magic filter
while (current < totalCount) {
const time = current * step;
const preview_duration = snippetLength;
args.push('-ss', time.toString(), '-t', preview_duration.toString(), '-i', pathToVideo);
concat += '[' + (current - 1) + ':V]' + '[' + (current - 1) + ':a]';
current++;
}
concat += 'concat=n=' + (totalCount - 1) + ':v=1:a=1[v][a];[v]scale=-2:' + clipHeight + '[v2]';
args.push('-filter_complex',
concat,
'-map',
'[v2]',
'-map',
'[a]',
savePath);
// phfff glad that's over
return args;
};
/**
* Extract the first frame from the preview clip
*
* @param pathToClip -- full path to where the .mp4 clip is located
* @param fileHash -- full path to where the .jpg should be saved
*/
const extractFirstFrameArgs = (
pathToClip: string,
pathToThumb: string
): string[] => {
const args: string[] = [
'-ss', '0',
'-i', pathToClip,
'-frames', '1',
'-f', 'image2',
pathToThumb,
];
return args;
};
// ========================================================================================
// Extraction engine
// ========================================================================================
/**
* Start extracting screenshots now that metadata has been retreived and sent over to the app
*
* DANGEROUSLY DEPENDS ON a global variable `globals.cancelCurrentImport`
* that can get toggled while scanning all screenshots
*
* Extract following this order. Each stage returns a boolean
* (^) means RESTART -- go back to (1) with the next item-to-extract on the list
*
* SOURCE FILE ============================
* (1) check if input file exists
* T: (2)
* F: (^) restart
* THUMB ==================================
* (2) check thumb exists
* T: (4)
* F: (3)
* (3) extract the SINGLE screenshot
* T: (4)
* F: (^) restart - assume corrupt
* FILMSTRIP ==============================
* (4) check filmstrip exists
* T: (6)
* F: (5)
* (5) extract the FILMSTRIP
* T: (clipSnippets === 0) ?
* T: nothing to do (^) restart
* F: (6)
* F: (^) restart - assume corrupt
* CLIP ===================================
* (6) check clip exists
* T: (8)
* F: (7)
* (7) extract the CLIP
* T: (8)
* F: (^) restart - assume corrupt
* CLIP THUMB =============================
* (8) check clip thumb exists
* T: (^) restart
* F: (9)
* (9) extract the CLIP preview
* T: (^) restart
* F: (^) restart
*
* @param theFinalArray -- finalArray of ImageElements
* @param videoFolderPath -- path to base folder where videos are
* @param screenshotFolder -- path to folder where .jpg files will be saved
* @param screenshotSettings -- ScreenshotSettings object
* @param elementsToScan -- array of indexes of elements in finalArray for which to extract screenshots
*/
export function extractFromTheseFiles(
theFinalArray: ImageElement[],
videoFolderPath: string,
screenshotFolder: string,
screenshotSettings: ScreenshotSettings,
elementsToScan: number[],
): void {
const clipHeight: number = screenshotSettings.clipHeight; // -- number in px how tall each clip should be
const clipSnippets: number = screenshotSettings.clipSnippets; // -- number of clip snippets to extract; 0 == do not extract clip
const screenshotHeight: number = screenshotSettings.height; // -- number in px how tall each screenshot should be
const snippetLength: number = screenshotSettings.clipSnippetLength; // -- length of each snippet in the clip
// final array already saved at this point - nothing to update inside it
// just walk through `elementsToScan` to extract screenshots for elements in `theFinalArray`
const itemTotal = elementsToScan.length;
let iterator = -1; // gets incremented to 0 on first call
const extractIterator = (): void => {
iterator++;
if ((iterator < itemTotal) && !globals.cancelCurrentImport) {
sendCurrentProgress(iterator, itemTotal, 'importingScreenshots');
const currentElement = elementsToScan[iterator];
const pathToVideo: string = (path.join(videoFolderPath,
theFinalArray[currentElement].partialPath,
theFinalArray[currentElement].fileName));
const duration: number = theFinalArray[currentElement].duration;
const fileHash: string = theFinalArray[currentElement].hash;
const numOfScreens: number = theFinalArray[currentElement].screens;
const thumbnailSavePath: string = screenshotFolder + '/thumbnails/' + fileHash + '.jpg';
const filmstripSavePath: string = screenshotFolder + '/filmstrips/' + fileHash + '.jpg';
const clipSavePath: string = screenshotFolder + '/clips/' + fileHash + '.mp4';
const clipThumbSavePath: string = screenshotFolder + '/clips/' + fileHash + '.jpg';
const maxRunTime: ExtractionDurations = setExtractionDurations(
numOfScreens, screenshotHeight, clipSnippets, snippetLength, clipHeight
);
checkFileExists(pathToVideo) // (1)
.then((videoFileExists: boolean) => {
// console.log('01 - video file live = ' + videoFileExists);
if (!videoFileExists) {
throw new Error('VIDEO FILE NOT PRESENT');
} else {
return checkFileExists(thumbnailSavePath); // (2)
}
})
.then((thumbExists: boolean) => {
// console.log('02 - thumbnail already present = ' + thumbExists);
if (thumbExists) {
return true;
} else {
const ffmpegArgs: string[] = extractSingleFrameArgs(
pathToVideo, screenshotHeight, duration, thumbnailSavePath
);
return spawn_ffmpeg_and_run(ffmpegArgs, maxRunTime.thumb, 'thumb'); // (3)
}
})
.then((thumbSuccess: boolean) => {
// console.log('03 - single screenshot now present = ' + thumbSuccess);
if (!thumbSuccess) {
throw new Error('SINGLE SCREENSHOT EXTRACTION TIMED OUT - LIKELY CORRUPT');
} else {
return checkFileExists(filmstripSavePath); // (4)
}
})
.then((filmstripExists: boolean) => {
// console.log('04 - filmstrip already present = ' + filmstripExists);
if (filmstripExists) {
return true;
} else {
const ffmpegArgs: string [] = generateScreenshotStripArgs(
pathToVideo, duration, screenshotHeight, numOfScreens, filmstripSavePath
);
return spawn_ffmpeg_and_run(ffmpegArgs, maxRunTime.filmstrip, 'filmstrip'); // (5)
}
})
.then((filmstripSuccess: boolean) => {
// console.log('05 - filmstrip now present = ' + filmstripSuccess);
if (!filmstripSuccess) {
throw new Error('FILMSTRIP GENERATION TIMED OUT - LIKELY CORRUPT');
} else if (clipSnippets === 0) {
throw new Error('USER DOES NOT WANT CLIPS');
} else {
return checkFileExists(clipSavePath); // (6)
}
})
.then((clipExists: boolean) => {
// console.log('04 - preview clip already present = ' + clipExists);
if (clipExists) {
return true;
} else {
const ffmpegArgs: string[] = generatePreviewClipArgs(
pathToVideo, duration, clipHeight, clipSnippets, snippetLength, clipSavePath
);
return spawn_ffmpeg_and_run(ffmpegArgs, maxRunTime.clip, 'clip'); // (7)
}
})
.then((clipGenerationSuccess: boolean) => {
// console.log('07 - preview clip now present = ' + clipGenerationSuccess);
if (clipGenerationSuccess) {
return checkFileExists(clipThumbSavePath); // (8)
} else {
throw new Error('ERROR GENERATING CLIP');
}
})
.then((clipThumbExists: boolean) => {
// console.log('05 - preview clip thumb already present = ' + clipThumbExists);
if (clipThumbExists) {
return true;
} else {
const ffmpegArgs: string[] = extractFirstFrameArgs(clipSavePath, clipThumbSavePath);
return spawn_ffmpeg_and_run(ffmpegArgs, maxRunTime.clipThumb, 'clip thumb'); // (9)
}
})
.then((success: boolean) => {
// console.log('09 - preview clip thumb now exists = ' + success);
if (success) {
// console.log('======= ALL STEPS SUCCESSFUL ==========');
}
extractIterator(); // resume iterating
})
.catch((err) => {
// console.log('===> ERROR - RESTARTING: ' + err);
extractIterator(); // resume iterating
});
} else {
sendCurrentProgress(1, 1, 'done'); // indicates 100%
}
};
extractIterator();
}
// ========================================================================================
// Helper methods
// ========================================================================================
interface ExtractionDurations {
thumb: number;
filmstrip: number;
clip: number;
clipThumb: number;
}
/**
* Set the ExtractionDurations - the maximum running time per extraction type
* if ffmpeg takes longer, it is taken out the back and shot - killed with no mercy
*
* These computations are not exact, meant to give a rough timeout window
* to prevent corrupt files from slowing down the extraction too much
*
* @param numOfScreens
* @param screenshotHeight
* @param clipSnippets
* @param snippetLength
* @param clipHeight
*/
function setExtractionDurations(
numOfScreens: number,
screenshotHeight: number,
clipSnippets: number,
snippetLength: number,
clipHeight: number,
): ExtractionDurations {
// screenshot heights range from 144px to 432px
// we'll call 144 the baseline and increase duration based on this
// this means at highest resolution we *tripple* the time we wait
const thumbHeightFactor = screenshotHeight / 144;
const clipHeightFactor = clipHeight / 144;
return { // for me:
thumb: 500 * thumbHeightFactor, // never above 300ms
filmstrip: 700 * numOfScreens * thumbHeightFactor, // rarely above 15s, but 4K 30screens took 50s
clip: 1000 * clipSnippets * snippetLength * clipHeightFactor, // barely ever above 15s
clipThumb: 300 * clipHeightFactor, // never above 100ms
};
}
/**
* Return promise for whether file exists
* @param pathToFile string
*/
function checkFileExists(pathToFile: string): Promise<boolean> {
return new Promise((resolve, reject) => {
if (fs.existsSync(pathToFile)) {
return resolve(true);
} else {
return resolve(false);
}
});
}
/**
* Replace original file with new file
* use ffmpeg to convert and letterbox to fit width and height
*
* @param oldFile full path to thumbnail to replace
* @param newFile full path to sounce image to use as replacement
* @param height
*/
export function replaceThumbnailWithNewImage(
oldFile: string,
newFile: string,
height: number
): Promise<boolean> {
console.log('Resizing new image and replacing old thumbnail');
const width: number = Math.floor(height * (16 / 9));
const args = [
'-y', '-i', newFile,
'-vf', scaleAndPadString(width, height),
oldFile,
];
return spawn_ffmpeg_and_run(args, 1000, 'replacing thumbnail');
// resizing an image file with ffmpeg should take less than 1 second
}
/**
* Generate the correct `scale=` & `pad=` string for ffmpeg
* @param width
* @param height
*/
function scaleAndPadString(width: number, height: number): string {
// sweet thanks to StackExchange!
// https://superuser.com/questions/547296/resizing-videos-with-ffmpeg-avconv-to-fit-into-static-sized-player
return 'scale=w=' + width + ':h=' + height + ':force_original_aspect_ratio=decrease,' +
'pad=' + width + ':' + height + ':(ow-iw)/2:(oh-ih)/2';
}
/**
* Spawn ffmpeg and run the appropriate arguments
* Kill the process after maxRunningTime
* @param args args to pass into ffmpeg
* @param maxRunningTime maximum time to run ffmpeg
* @param description log for console.log
*/
function spawn_ffmpeg_and_run(
args: string[],
maxRunningTime: number,
description: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
// const t0: number = performance.now();
const ffmpeg_process = spawn(ffmpegPath, args);
const killProcessTimeout = setTimeout(() => {
if (!ffmpeg_process.killed) {
ffmpeg_process.kill();
// console.log(description + ' KILLED EARLY');
return resolve(false);
}
}, maxRunningTime);
// Note from past Cal to future Cal:
// ALWAYS READ THE DATA, EVEN IF YOU DO NOTHING WITH IT
ffmpeg_process.stdout.on('data', data => {
if (globals.debug) {
console.log(data);
}
});
ffmpeg_process.stderr.on('data', data => {
if (globals.debug) {
console.log('grep stderr: ' + data);
}
});
ffmpeg_process.on('exit', () => {
clearTimeout(killProcessTimeout);
// const t1: number = performance.now();
// console.log(description + ': ' + (t1 - t0).toString());
return resolve(true);
});
});
}