nodejs-audio-visualizer 5.3.0 → 5.4.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/README.md +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/highlight.d.ts +27 -2
- package/dist/highlight.js +201 -66
- package/dist/highlight.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +139 -70
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +19 -0
- package/dist/video.js +62 -3
- package/dist/video.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/highlight.ts +203 -37
- package/src/index.ts +83 -14
- package/src/tests/config.test.ts +19 -0
- package/src/tests/highlight.test.ts +136 -0
- package/src/tests/video.test.ts +80 -1
- package/src/video.ts +81 -0
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getAudioFilePath,
|
|
5
5
|
getBackgroundImagePath,
|
|
6
6
|
getBackgroundVideoPaths,
|
|
7
|
+
getHookVideoPath,
|
|
7
8
|
getOutVideoPath,
|
|
8
9
|
getSubtitleRenderSpec,
|
|
9
10
|
subtitleAlignmentToAss,
|
|
@@ -40,9 +41,9 @@ import {
|
|
|
40
41
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
|
|
41
42
|
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_DELAY_SECONDS, CAMERA_SHAKE_EVERY_BEATS } from './image';
|
|
42
43
|
import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
|
|
43
|
-
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
|
|
44
|
+
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, computeHookFrameCount, getVideoFrameCount, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
|
|
44
45
|
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
45
|
-
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
46
|
+
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, shiftTempoPhase, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
46
47
|
export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
|
|
47
48
|
import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
|
|
48
49
|
import { waitForEventLoop } from './waitForEventLoop';
|
|
@@ -74,6 +75,14 @@ export interface Config {
|
|
|
74
75
|
* videos instead of auto-detecting scene changes within a single video.
|
|
75
76
|
*/
|
|
76
77
|
path: string | string[];
|
|
78
|
+
/**
|
|
79
|
+
* Video played once, always in full, before the `path` video(s). When `audio.autoHighlight`
|
|
80
|
+
* is true, each highlight is chosen so the hook ends on a detected drop and `path` starts with it,
|
|
81
|
+
* playing ~15s from the drop (the hook comes on top of the ~15s rather than being counted in it).
|
|
82
|
+
* If the drop is closer to the track start than the hook's length, the track is preceded by silence.
|
|
83
|
+
* The hook's sound, if any, is mixed over the track, which fades in from silence to full volume while the hook plays.
|
|
84
|
+
*/
|
|
85
|
+
hookPath?: string;
|
|
77
86
|
autoEdit?: boolean;
|
|
78
87
|
/** When `autoEdit` is true, disables the brief camera shake applied on cuts, or every 2 beats when there are no cuts (default true). */
|
|
79
88
|
cameraShake?: boolean;
|
|
@@ -284,6 +293,10 @@ async function prepareBackgroundForRender(params: {
|
|
|
284
293
|
tempo?: TempoEstimate | null;
|
|
285
294
|
/** First video to play for `video.path` arrays without `autoEdit`. */
|
|
286
295
|
startVideoIndex?: number;
|
|
296
|
+
/** Video played once before the background videos. */
|
|
297
|
+
hookVideoPath?: string;
|
|
298
|
+
/** Hook length in frames (the lead-in up to a detected drop); the full hook when unset. */
|
|
299
|
+
hookFrameCount?: number;
|
|
287
300
|
}): Promise<{
|
|
288
301
|
backgroundWidth: number;
|
|
289
302
|
backgroundHeight: number;
|
|
@@ -294,6 +307,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
294
307
|
encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
|
|
295
308
|
concatFilePath?: string;
|
|
296
309
|
cutFrameIndices: number[];
|
|
310
|
+
/** Hook video whose sound plays over the first `frameCount` output frames. */
|
|
311
|
+
hookAudio?: { path: string; frameCount: number };
|
|
297
312
|
}> {
|
|
298
313
|
const {
|
|
299
314
|
useVideoBackground,
|
|
@@ -308,6 +323,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
308
323
|
readVideoFrameTimeout,
|
|
309
324
|
tempo,
|
|
310
325
|
startVideoIndex = 0,
|
|
326
|
+
hookVideoPath,
|
|
327
|
+
hookFrameCount,
|
|
311
328
|
} = params;
|
|
312
329
|
|
|
313
330
|
if (useVideoBackground && backgroundVideoPaths && backgroundVideoPaths.length > 0) {
|
|
@@ -319,21 +336,43 @@ async function prepareBackgroundForRender(params: {
|
|
|
319
336
|
const videoFrameSize = backgroundWidth * backgroundHeight * 3;
|
|
320
337
|
const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
321
338
|
|
|
339
|
+
const hookVideoInfo = hookVideoPath ? await getVideoInfo(hookVideoPath) : undefined;
|
|
340
|
+
// Real frame count, so the hook never has to wrap back to its start.
|
|
341
|
+
const hookAvailableFrames = hookVideoPath ? await getVideoFrameCount(hookVideoPath, fps) : 0;
|
|
342
|
+
const hookFrames = !hookVideoInfo
|
|
343
|
+
? 0
|
|
344
|
+
: hookFrameCount !== undefined
|
|
345
|
+
? Math.min(hookFrameCount, hookAvailableFrames, framesCount)
|
|
346
|
+
: computeHookFrameCount(hookAvailableFrames / fps, fps, framesCount);
|
|
347
|
+
// The background videos fill the frames after the hook, on a timeline starting at the hook's end.
|
|
348
|
+
const mainFramesCount = framesCount - hookFrames;
|
|
349
|
+
const mainBeatFrameIndices: number[] = [];
|
|
350
|
+
const mainBeatIntensities: number[] | undefined = beatIntensities ? [] : undefined;
|
|
351
|
+
beatFrameIndices.forEach((frameIndex, i) => {
|
|
352
|
+
if (frameIndex >= hookFrames) {
|
|
353
|
+
mainBeatFrameIndices.push(frameIndex - hookFrames);
|
|
354
|
+
mainBeatIntensities?.push(beatIntensities![i]);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const mainTempo = tempo ? shiftTempoPhase(tempo, hookFrames) : tempo;
|
|
358
|
+
|
|
322
359
|
let segments: VideoSegment[];
|
|
323
|
-
if (
|
|
360
|
+
if (mainFramesCount <= 0) {
|
|
361
|
+
segments = [];
|
|
362
|
+
} else if (backgroundVideoPaths.length > 1) {
|
|
324
363
|
segments = autoEditVideo
|
|
325
364
|
? buildShuffledVideoSegments(
|
|
326
|
-
|
|
327
|
-
|
|
365
|
+
mainBeatFrameIndices,
|
|
366
|
+
mainFramesCount,
|
|
328
367
|
backgroundVideoPaths.length,
|
|
329
368
|
fps,
|
|
330
369
|
{
|
|
331
|
-
beatIntensities,
|
|
332
|
-
...(
|
|
370
|
+
beatIntensities: mainBeatIntensities,
|
|
371
|
+
...(mainTempo ? { tempo: mainTempo } : {}),
|
|
333
372
|
},
|
|
334
373
|
)
|
|
335
374
|
: buildSequentialVideoSegments(
|
|
336
|
-
|
|
375
|
+
mainFramesCount,
|
|
337
376
|
videoInfos.map(info => info.duration),
|
|
338
377
|
fps,
|
|
339
378
|
startVideoIndex % backgroundVideoPaths.length,
|
|
@@ -343,19 +382,29 @@ async function prepareBackgroundForRender(params: {
|
|
|
343
382
|
? await detectSceneChanges(backgroundVideoPaths[0])
|
|
344
383
|
: [];
|
|
345
384
|
segments = buildBeatSyncedSegments(
|
|
346
|
-
|
|
347
|
-
|
|
385
|
+
mainBeatFrameIndices,
|
|
386
|
+
mainFramesCount,
|
|
348
387
|
sceneChanges,
|
|
349
388
|
primaryVideoInfo.duration,
|
|
350
389
|
fps,
|
|
351
390
|
{
|
|
352
|
-
beatIntensities,
|
|
353
|
-
...(autoEditVideo &&
|
|
391
|
+
beatIntensities: mainBeatIntensities,
|
|
392
|
+
...(autoEditVideo && mainTempo ? { tempo: mainTempo } : {}),
|
|
354
393
|
},
|
|
355
394
|
);
|
|
356
395
|
}
|
|
357
396
|
|
|
358
397
|
const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
|
|
398
|
+
if (hookVideoPath && hookVideoInfo && hookFrames > 0) {
|
|
399
|
+
segments = [
|
|
400
|
+
{ outputStartFrame: 0, videoSeekSeconds: 0, frameCount: hookFrames, videoIndex: videoSources.length },
|
|
401
|
+
...segments.map(segment => ({
|
|
402
|
+
...segment,
|
|
403
|
+
outputStartFrame: segment.outputStartFrame + hookFrames,
|
|
404
|
+
})),
|
|
405
|
+
];
|
|
406
|
+
videoSources.push({ path: hookVideoPath, duration: hookAvailableFrames / fps });
|
|
407
|
+
}
|
|
359
408
|
const concatFilePath = writeConcatFile(segments, videoSources, fps);
|
|
360
409
|
const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
|
|
361
410
|
|
|
@@ -377,7 +426,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
377
426
|
readVideoFrameTimeout,
|
|
378
427
|
);
|
|
379
428
|
if (!firstFrame) {
|
|
380
|
-
throw new Error(`Could not read frames from video: ${
|
|
429
|
+
throw new Error(`Could not read frames from video: ${videoSources.map(source => source.path).join(', ')}`);
|
|
381
430
|
}
|
|
382
431
|
const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
|
|
383
432
|
const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
|
|
@@ -391,6 +440,9 @@ async function prepareBackgroundForRender(params: {
|
|
|
391
440
|
encodeVideoFrame,
|
|
392
441
|
concatFilePath,
|
|
393
442
|
cutFrameIndices,
|
|
443
|
+
...(hookVideoPath && hookVideoInfo?.hasAudio && hookFrames > 0 && {
|
|
444
|
+
hookAudio: { path: hookVideoPath, frameCount: hookFrames },
|
|
445
|
+
}),
|
|
394
446
|
};
|
|
395
447
|
}
|
|
396
448
|
|
|
@@ -501,6 +553,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
501
553
|
}
|
|
502
554
|
}
|
|
503
555
|
const backgroundVideoPaths = getBackgroundVideoPaths(config);
|
|
556
|
+
const hookVideoPath = getHookVideoPath(config);
|
|
504
557
|
const backgroundImagePath = getBackgroundImagePath(config);
|
|
505
558
|
const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
|
|
506
559
|
|
|
@@ -572,6 +625,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
572
625
|
|
|
573
626
|
if (autoHighlight) {
|
|
574
627
|
reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
|
|
628
|
+
// With a hook, highlights are built so the hook ends on a detected drop.
|
|
629
|
+
const hookLeadInFrames = hookVideoPath
|
|
630
|
+
? await getVideoFrameCount(hookVideoPath, FPS)
|
|
631
|
+
: undefined;
|
|
575
632
|
highlightSlice = await computeHighlightSlice(
|
|
576
633
|
FPS,
|
|
577
634
|
framesCount,
|
|
@@ -579,6 +636,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
579
636
|
preprocessed.beatEvents,
|
|
580
637
|
getAudioAutoHighlightCount(config),
|
|
581
638
|
trackTempo,
|
|
639
|
+
hookLeadInFrames,
|
|
582
640
|
);
|
|
583
641
|
spectrumsForRender = highlightSlice.spectrums;
|
|
584
642
|
beatIndicesForRender = highlightSlice.beatFrameIndices;
|
|
@@ -605,6 +663,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
605
663
|
frameCount: number;
|
|
606
664
|
startFrame: number;
|
|
607
665
|
audioSegment: import('./highlight').HighlightAudioSegment | undefined;
|
|
666
|
+
/** Frames until the detected drop, where the hook should end. */
|
|
667
|
+
leadInFrames?: number;
|
|
608
668
|
};
|
|
609
669
|
|
|
610
670
|
const passes: VideoRenderPass[] =
|
|
@@ -622,6 +682,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
622
682
|
frameCount: run.highlightFrames,
|
|
623
683
|
startFrame: run.startFrame,
|
|
624
684
|
audioSegment: run.audioSegment,
|
|
685
|
+
leadInFrames: run.leadInFrames,
|
|
625
686
|
};
|
|
626
687
|
})
|
|
627
688
|
: [
|
|
@@ -638,6 +699,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
638
699
|
highlightSlice.audioSegments.length > 0
|
|
639
700
|
? highlightSlice.audioSegments[0]
|
|
640
701
|
: undefined,
|
|
702
|
+
leadInFrames: highlightSlice?.runs.length === 1 ? highlightSlice.runs[0].leadInFrames : undefined,
|
|
641
703
|
},
|
|
642
704
|
];
|
|
643
705
|
|
|
@@ -659,9 +721,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
659
721
|
try {
|
|
660
722
|
passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
|
|
661
723
|
const pass = passes[passIndex];
|
|
662
|
-
const
|
|
724
|
+
const windowTempo = trackTempo
|
|
663
725
|
? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
|
|
664
726
|
: null;
|
|
727
|
+
const tempo = autoEditVideo ? windowTempo : null;
|
|
665
728
|
const shakeAmplitudes = tempo
|
|
666
729
|
? buildCutShakeAmplitudes(tempo.periodFrames)
|
|
667
730
|
: undefined;
|
|
@@ -675,6 +738,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
675
738
|
encodeVideoFrame,
|
|
676
739
|
concatFilePath,
|
|
677
740
|
cutFrameIndices,
|
|
741
|
+
hookAudio,
|
|
678
742
|
} = await prepareBackgroundForRender({
|
|
679
743
|
useVideoBackground,
|
|
680
744
|
backgroundVideoPaths,
|
|
@@ -689,6 +753,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
689
753
|
...(tempo ? { tempo } : {}),
|
|
690
754
|
// Each separate highlight output opens with a different video from the array.
|
|
691
755
|
startVideoIndex: passIndex,
|
|
756
|
+
hookVideoPath,
|
|
757
|
+
...(pass.leadInFrames !== undefined ? { hookFrameCount: pass.leadInFrames } : {}),
|
|
692
758
|
});
|
|
693
759
|
reportRenderProgress();
|
|
694
760
|
|
|
@@ -713,6 +779,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
713
779
|
}),
|
|
714
780
|
fps: FPS,
|
|
715
781
|
...(pass.audioSegment && { audioSegment: pass.audioSegment }),
|
|
782
|
+
...(hookAudio && {
|
|
783
|
+
overlayAudio: { filename: hookAudio.path, durationSeconds: hookAudio.frameCount / FPS },
|
|
784
|
+
}),
|
|
716
785
|
...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
|
|
717
786
|
...(ffmpeg_preset && { preset: ffmpeg_preset }),
|
|
718
787
|
});
|
package/src/tests/config.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getAudioAutoHighlightCount,
|
|
9
9
|
getBackgroundImagePath,
|
|
10
10
|
getBackgroundVideoPaths,
|
|
11
|
+
getHookVideoPath,
|
|
11
12
|
getCameraShakeEnabled,
|
|
12
13
|
getOutVideoPath,
|
|
13
14
|
getSubtitleRenderSpec,
|
|
@@ -93,6 +94,24 @@ describe('config', function() {
|
|
|
93
94
|
).to.throw('Invalid video.path');
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
it('getHookVideoPath', function() {
|
|
98
|
+
expect(
|
|
99
|
+
getHookVideoPath({ audio: { path: 'a.wav' } } as Config)
|
|
100
|
+
).equal(undefined);
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
getHookVideoPath({ video: { path: 'a.mp4' } } as Config)
|
|
104
|
+
).equal(undefined);
|
|
105
|
+
|
|
106
|
+
expect(
|
|
107
|
+
getHookVideoPath({ video: { path: 'a.mp4', hookPath: 'hook.mp4' } } as Config)
|
|
108
|
+
).equal(path.resolve('hook.mp4'));
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
getHookVideoPath.bind(undefined, { video: { path: 'a.mp4', hookPath: '' } } as Config)
|
|
112
|
+
).to.throw('Invalid video.hookPath');
|
|
113
|
+
});
|
|
114
|
+
|
|
96
115
|
it('getCameraShakeEnabled', function() {
|
|
97
116
|
expect(
|
|
98
117
|
getCameraShakeEnabled({ audio: { path: 'a.wav' } } as Config)
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
MAX_BEAT_SNAP_SEC,
|
|
6
6
|
buildBeatCandidates,
|
|
7
7
|
snapSegmentToBeats,
|
|
8
|
+
snapDropToBeat,
|
|
8
9
|
} from '../highlight';
|
|
9
10
|
|
|
10
11
|
const dummySpectrums = (n: number) => Array.from({ length: n }, () => [0]);
|
|
@@ -183,6 +184,115 @@ describe('computeHighlightSlice', function() {
|
|
|
183
184
|
expect(result.runs).have.length(1);
|
|
184
185
|
});
|
|
185
186
|
|
|
187
|
+
it('starts the whole lead-in before the drop when leadInFrames is given', async function() {
|
|
188
|
+
const fps = 10;
|
|
189
|
+
const totalFrames = 1000;
|
|
190
|
+
// Quiet until frame 600, loud after: the drop is at 600.
|
|
191
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
|
|
192
|
+
const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
193
|
+
|
|
194
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
|
|
195
|
+
|
|
196
|
+
expect(result.runs).have.length(1);
|
|
197
|
+
expect(result.runs[0].startFrame).equal(557);
|
|
198
|
+
expect(result.runs[0].leadInFrames).equal(43);
|
|
199
|
+
// The lead-in comes on top of the ~15s that follow the drop.
|
|
200
|
+
expect(result.runs[0].highlightFrames).equal(43 + Math.ceil(HIGHLIGHT_DURATION_SEC * fps));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('pads the lead-in with silence before the track start when only an early drop fits', async function() {
|
|
204
|
+
const fps = 10;
|
|
205
|
+
// A full 130-frame lead-in needs a drop at 130+, but ~15s must follow it: only 100 fits.
|
|
206
|
+
const totalFrames = 250;
|
|
207
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 100, totalFrames);
|
|
208
|
+
const beats = Array.from({ length: 50 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
209
|
+
|
|
210
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 130);
|
|
211
|
+
const run = result.runs[0];
|
|
212
|
+
|
|
213
|
+
expect(run.startFrame).equal(-30);
|
|
214
|
+
expect(run.leadInFrames).equal(130);
|
|
215
|
+
expect(run.spectrums).have.length(run.highlightFrames);
|
|
216
|
+
expect(run.spectrums.slice(0, 30).every(s => s.every(v => v === 0))).equal(true);
|
|
217
|
+
expect(run.beatFrameIndices[0]).equal(30);
|
|
218
|
+
expect(run.audioSegment).deep.equal({
|
|
219
|
+
seekSeconds: 0,
|
|
220
|
+
durationSeconds: (run.highlightFrames - 30) / fps,
|
|
221
|
+
delaySeconds: 3,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('picks a separate drop for each highlight', async function() {
|
|
226
|
+
const fps = 10;
|
|
227
|
+
const totalFrames = 2000;
|
|
228
|
+
const spectrums = Array.from({ length: totalFrames }, (_, i) =>
|
|
229
|
+
(i >= 500 && i < 800) || (i >= 1400 && i < 1700) ? [1] : [0],
|
|
230
|
+
);
|
|
231
|
+
const beats = Array.from({ length: 400 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
232
|
+
|
|
233
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 2, null, 30);
|
|
234
|
+
|
|
235
|
+
expect(result.runs.map(run => run.startFrame + (run.leadInFrames as number))).deep.equal([500, 1400]);
|
|
236
|
+
expect(result.runs.map(run => run.leadInFrames)).deep.equal([30, 30]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('ends the lead-in on the energy onset even when beats are off it', async function() {
|
|
240
|
+
const fps = 10;
|
|
241
|
+
const totalFrames = 1000;
|
|
242
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
|
|
243
|
+
// Beats every 5 frames, none on the onset at 602.
|
|
244
|
+
const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
245
|
+
|
|
246
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
|
|
247
|
+
|
|
248
|
+
expect(result.runs[0].startFrame + (result.runs[0].leadInFrames as number)).equal(602);
|
|
249
|
+
expect(result.runs[0].leadInFrames).equal(43);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('ends the lead-in on a nearby grid beat when a tempo grid is given', async function() {
|
|
253
|
+
const fps = 10;
|
|
254
|
+
const totalFrames = 1000;
|
|
255
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
|
|
256
|
+
const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
257
|
+
// Grid beats every 10 frames on multiples of 10: 600 is 2 frames (0.2 beat) from the onset.
|
|
258
|
+
const grid = { periodFrames: 10, phaseFrame: 0 };
|
|
259
|
+
|
|
260
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, grid, 43);
|
|
261
|
+
|
|
262
|
+
expect(result.runs[0].startFrame).equal(557);
|
|
263
|
+
expect(result.runs[0].leadInFrames).equal(43);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('keeps the full lead-in rather than ending it past the drop or on a weak rise', async function() {
|
|
267
|
+
const fps = 10;
|
|
268
|
+
const totalFrames = 600;
|
|
269
|
+
// Big drop at 140, then a small bump at 300; a 200-frame lead-in doesn't fit before 140.
|
|
270
|
+
const spectrums = Array.from({ length: totalFrames }, (_, i) =>
|
|
271
|
+
i < 140 ? [0.1] : i < 300 ? [1] : [1.1],
|
|
272
|
+
);
|
|
273
|
+
const beats = Array.from({ length: 120 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
|
|
274
|
+
|
|
275
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 200);
|
|
276
|
+
|
|
277
|
+
expect(result.runs[0].startFrame).equal(-60);
|
|
278
|
+
expect(result.runs[0].leadInFrames).equal(200);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('puts the lead-in before the energy window when no drop is found', async function() {
|
|
282
|
+
const fps = 10;
|
|
283
|
+
const totalFrames = 1000;
|
|
284
|
+
const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
|
|
285
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
|
|
286
|
+
|
|
287
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, null, 900);
|
|
288
|
+
|
|
289
|
+
// The window starts at 600; the rest of the lead-in is silence before the track start.
|
|
290
|
+
expect(result.runs[0].startFrame).equal(-300);
|
|
291
|
+
expect(result.runs[0].leadInFrames).equal(900);
|
|
292
|
+
expect(result.runs[0].highlightFrames).equal(900 + highlightFrames);
|
|
293
|
+
expect(result.runs[0].audioSegment.delaySeconds).equal(30);
|
|
294
|
+
});
|
|
295
|
+
|
|
186
296
|
it('snaps highlight start and end onto detected beats', async function() {
|
|
187
297
|
const fps = 30;
|
|
188
298
|
const totalFrames = 1000;
|
|
@@ -259,3 +369,29 @@ describe('snapSegmentToBeats', function() {
|
|
|
259
369
|
expect(snapSegmentToBeats(seg, 1000, [10, 900], 20)).deep.equal(seg);
|
|
260
370
|
});
|
|
261
371
|
});
|
|
372
|
+
|
|
373
|
+
describe('snapDropToBeat', function() {
|
|
374
|
+
it('moves the drop and its start together onto a beat within a fifth of a beat', function() {
|
|
375
|
+
const drop = { startFrame: 60, dropFrame: 103 };
|
|
376
|
+
snapDropToBeat(drop, [80, 100, 120], 20);
|
|
377
|
+
expect(drop).deep.equal({ startFrame: 57, dropFrame: 100 });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('keeps the drop when the nearest beat is too far', function() {
|
|
381
|
+
const drop = { startFrame: 60, dropFrame: 105 };
|
|
382
|
+
snapDropToBeat(drop, [80, 100, 120], 20);
|
|
383
|
+
expect(drop).deep.equal({ startFrame: 60, dropFrame: 105 });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('keeps the drop without a tempo period', function() {
|
|
387
|
+
const drop = { startFrame: 60, dropFrame: 101 };
|
|
388
|
+
snapDropToBeat(drop, [80, 100, 120]);
|
|
389
|
+
expect(drop).deep.equal({ startFrame: 60, dropFrame: 101 });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('does not push a lead-in that fit in the track back past its start', function() {
|
|
393
|
+
const drop = { startFrame: 1, dropFrame: 43 };
|
|
394
|
+
snapDropToBeat(drop, [40], 20);
|
|
395
|
+
expect(drop).deep.equal({ startFrame: 1, dropFrame: 43 });
|
|
396
|
+
});
|
|
397
|
+
});
|
package/src/tests/video.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
2
|
import { Writable, Readable, Pipe } from 'stream';
|
|
3
3
|
import { EventEmitter } from 'events';
|
|
4
|
-
import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
|
|
4
|
+
import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, computeHookFrameCount, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
|
|
5
5
|
import { createSandbox, SinonStub } from 'sinon';
|
|
6
6
|
import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
|
|
7
7
|
|
|
@@ -70,6 +70,80 @@ describe('video', function () {
|
|
|
70
70
|
expect(spawnArgs[tIdx + 1]).to.equal('15');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it('spawnFfmpegVideoWriter mixes overlay audio while the track fades in', function () {
|
|
74
|
+
const childProcessReadableStream = new Readable();
|
|
75
|
+
childProcessReadableStream._read = () => { };
|
|
76
|
+
const childProcessWritableStream = new Writable();
|
|
77
|
+
(<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
|
|
78
|
+
|
|
79
|
+
childProcessStream.stdin = childProcessWritableStream;
|
|
80
|
+
childProcessStream.stderr = childProcessReadableStream;
|
|
81
|
+
|
|
82
|
+
let spawnArgs: string[] = [];
|
|
83
|
+
const spawnStub = child_process.spawn as SinonStub;
|
|
84
|
+
spawnStub.callsFake((_cmd: string, args: string[]) => {
|
|
85
|
+
spawnArgs = args;
|
|
86
|
+
return childProcessStream as ChildProcessWithoutNullStreams;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
spawnFfmpegVideoWriter({
|
|
90
|
+
audioFilename: 'audio.mp3',
|
|
91
|
+
videoFileName: 'out.mp4',
|
|
92
|
+
fps: 25,
|
|
93
|
+
overlayAudio: { filename: 'hook.mp4', durationSeconds: 3.2 },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
spawnStub.resetBehavior();
|
|
97
|
+
spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
|
|
98
|
+
|
|
99
|
+
const inputs = spawnArgs.filter((_, i) => spawnArgs[i - 1] === '-i');
|
|
100
|
+
expect(inputs).deep.equal(['audio.mp3', '-', 'hook.mp4']);
|
|
101
|
+
const hookIdx = spawnArgs.indexOf('hook.mp4');
|
|
102
|
+
expect(spawnArgs.slice(hookIdx - 3, hookIdx - 1)).deep.equal(['-t', '3.2']);
|
|
103
|
+
const filter = spawnArgs[spawnArgs.indexOf('-filter_complex') + 1];
|
|
104
|
+
expect(filter).to.include("[0:a]aformat=channel_layouts=stereo,volume='if(gte(t,3.2),1,pow(t/3.2,5))':eval=frame[track]");
|
|
105
|
+
expect(filter).to.include('[2:a]aformat=channel_layouts=stereo[overlay]');
|
|
106
|
+
expect(filter).to.include('[track][overlay]amix=inputs=2:duration=first');
|
|
107
|
+
expect(spawnArgs.join(' ')).to.include('-map 1:v -map [aout]');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('spawnFfmpegVideoWriter delays the track by the audio segment delay', function () {
|
|
111
|
+
const childProcessReadableStream = new Readable();
|
|
112
|
+
childProcessReadableStream._read = () => { };
|
|
113
|
+
const childProcessWritableStream = new Writable();
|
|
114
|
+
(<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
|
|
115
|
+
|
|
116
|
+
childProcessStream.stdin = childProcessWritableStream;
|
|
117
|
+
childProcessStream.stderr = childProcessReadableStream;
|
|
118
|
+
|
|
119
|
+
const spawnStub = child_process.spawn as SinonStub;
|
|
120
|
+
const run = (overlay: boolean) => {
|
|
121
|
+
let spawnArgs: string[] = [];
|
|
122
|
+
spawnStub.callsFake((_cmd: string, args: string[]) => {
|
|
123
|
+
spawnArgs = args;
|
|
124
|
+
return childProcessStream as ChildProcessWithoutNullStreams;
|
|
125
|
+
});
|
|
126
|
+
spawnFfmpegVideoWriter({
|
|
127
|
+
audioFilename: 'audio.mp3',
|
|
128
|
+
videoFileName: 'out.mp4',
|
|
129
|
+
fps: 25,
|
|
130
|
+
audioSegment: { seekSeconds: 0, durationSeconds: 12, delaySeconds: 1.5 },
|
|
131
|
+
...(overlay && { overlayAudio: { filename: 'hook.mp4', durationSeconds: 3.2 } }),
|
|
132
|
+
});
|
|
133
|
+
return spawnArgs;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const plainArgs = run(false);
|
|
137
|
+
expect(plainArgs[plainArgs.indexOf('-af') + 1]).equal('adelay=1500:all=1');
|
|
138
|
+
const overlayArgs = run(true);
|
|
139
|
+
spawnStub.resetBehavior();
|
|
140
|
+
spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
|
|
141
|
+
|
|
142
|
+
expect(overlayArgs).to.not.include('-af');
|
|
143
|
+
const filter = overlayArgs[overlayArgs.indexOf('-filter_complex') + 1];
|
|
144
|
+
expect(filter).to.include("[0:a]aformat=channel_layouts=stereo,adelay=1500:all=1,volume='if(gte(t,3.2),1,pow(t/3.2,5))':eval=frame[track]");
|
|
145
|
+
});
|
|
146
|
+
|
|
73
147
|
it('spawnFfmpegVideoWriter adds subtitles filter when subtitle file is provided', function () {
|
|
74
148
|
const childProcessReadableStream = new Readable();
|
|
75
149
|
childProcessReadableStream._read = () => { };
|
|
@@ -283,6 +357,11 @@ describe('video', function () {
|
|
|
283
357
|
expect(reason).equal('waitForProcessExit timeout (5ms)');
|
|
284
358
|
});
|
|
285
359
|
|
|
360
|
+
it('computeHookFrameCount plays the full hook', function () {
|
|
361
|
+
expect(computeHookFrameCount(2, 30, 300)).equal(60);
|
|
362
|
+
expect(computeHookFrameCount(20, 30, 300)).equal(300);
|
|
363
|
+
});
|
|
364
|
+
|
|
286
365
|
it('getCutFrameIndices skips the opening segment', function () {
|
|
287
366
|
const segments = buildBeatSyncedSegments(
|
|
288
367
|
[12, 24, 36],
|
package/src/video.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { TempoEstimate, beatGridFrameIndices } from './beats';
|
|
|
10
10
|
export interface AudioMuxSegment {
|
|
11
11
|
seekSeconds: number;
|
|
12
12
|
durationSeconds: number;
|
|
13
|
+
/** Silence played before the audio. */
|
|
14
|
+
delaySeconds?: number;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface FfmpegVideoWriterConfig {
|
|
@@ -27,6 +29,14 @@ interface FfmpegVideoWriterConfig {
|
|
|
27
29
|
audioDurationSeconds?: number;
|
|
28
30
|
/** One contiguous trim of the same audio file (muxed highlight). */
|
|
29
31
|
audioSegment?: AudioMuxSegment;
|
|
32
|
+
/**
|
|
33
|
+
* Audio mixed over the start of the track (e.g. the hook video's sound), from its beginning.
|
|
34
|
+
* The track fades in from silence to full volume while it plays, along an easeInQuint curve.
|
|
35
|
+
*/
|
|
36
|
+
overlayAudio?: {
|
|
37
|
+
filename: string;
|
|
38
|
+
durationSeconds: number;
|
|
39
|
+
};
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const escapeSubtitleFilterPath = (subtitlePath: string) =>
|
|
@@ -38,6 +48,13 @@ const escapeSubtitleFilterPath = (subtitlePath: string) =>
|
|
|
38
48
|
.replace(/\[/g, '\\[')
|
|
39
49
|
.replace(/\]/g, '\\]');
|
|
40
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Fade-in from silence to full volume over `durationSeconds`, following easeInQuint:
|
|
53
|
+
* gain(x) = x^5, where x is the fraction of the fade elapsed.
|
|
54
|
+
*/
|
|
55
|
+
const easeInQuintFadeFilter = (durationSeconds: number) =>
|
|
56
|
+
`volume='if(gte(t,${durationSeconds}),1,pow(t/${durationSeconds},5))':eval=frame`;
|
|
57
|
+
|
|
41
58
|
export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
|
|
42
59
|
if (!ffmpegPath) {
|
|
43
60
|
throw new Error('ffmpeg path not found');
|
|
@@ -58,6 +75,30 @@ export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
|
|
|
58
75
|
'-vcodec', 'bmp',
|
|
59
76
|
'-framerate', `${config.fps}`,
|
|
60
77
|
'-i', '-',
|
|
78
|
+
);
|
|
79
|
+
const delaySeconds = config.audioSegment?.delaySeconds ?? 0;
|
|
80
|
+
const trackDelayFilter = delaySeconds > 0
|
|
81
|
+
? `adelay=${Math.round(delaySeconds * 1000)}:all=1`
|
|
82
|
+
: '';
|
|
83
|
+
if (config.overlayAudio) {
|
|
84
|
+
const overlaySeconds = config.overlayAudio.durationSeconds;
|
|
85
|
+
args.push(
|
|
86
|
+
'-t', String(overlaySeconds),
|
|
87
|
+
'-i', config.overlayAudio.filename,
|
|
88
|
+
// Both inputs go to stereo first: mixing in e.g. a 5.1 hook otherwise yields a channel
|
|
89
|
+
// layout the AAC encoder rejects. `duration=first` keeps the output as long as the track.
|
|
90
|
+
'-filter_complex',
|
|
91
|
+
`[0:a]aformat=channel_layouts=stereo,${trackDelayFilter ? `${trackDelayFilter},` : ''}`
|
|
92
|
+
+ `${easeInQuintFadeFilter(overlaySeconds)}[track];`
|
|
93
|
+
+ '[2:a]aformat=channel_layouts=stereo[overlay];'
|
|
94
|
+
+ '[track][overlay]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]',
|
|
95
|
+
'-map', '1:v',
|
|
96
|
+
'-map', '[aout]',
|
|
97
|
+
);
|
|
98
|
+
} else if (trackDelayFilter) {
|
|
99
|
+
args.push('-af', trackDelayFilter);
|
|
100
|
+
}
|
|
101
|
+
args.push(
|
|
61
102
|
'-crf', crf,
|
|
62
103
|
'-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
|
|
63
104
|
'-c:v', 'libx264',
|
|
@@ -86,6 +127,7 @@ export interface VideoInfo {
|
|
|
86
127
|
width: number;
|
|
87
128
|
height: number;
|
|
88
129
|
duration: number;
|
|
130
|
+
hasAudio: boolean;
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
export interface SceneChange {
|
|
@@ -128,6 +170,37 @@ export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<
|
|
|
128
170
|
ffmpeg.on('error', reject);
|
|
129
171
|
});
|
|
130
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Number of whole frames the video stream yields at `fps` (partial last frames dropped).
|
|
175
|
+
* Unlike the container duration, this never runs past the last video frame (e.g. when the
|
|
176
|
+
* audio stream is longer), so a clip read for this many frames never wraps back to its start.
|
|
177
|
+
*/
|
|
178
|
+
export const getVideoFrameCount = (videoPath: string, fps: number): Promise<number> =>
|
|
179
|
+
new Promise((resolvePromise, reject) => {
|
|
180
|
+
if (!ffmpegPath) {
|
|
181
|
+
reject(new Error('ffmpeg path not found'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const ffmpeg = spawn(ffmpegPath, [
|
|
185
|
+
'-i', videoPath,
|
|
186
|
+
'-map', '0:v:0',
|
|
187
|
+
'-vf', `fps=${fps}:round=down`,
|
|
188
|
+
'-f', 'null',
|
|
189
|
+
'-',
|
|
190
|
+
]);
|
|
191
|
+
let stderr = '';
|
|
192
|
+
ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
193
|
+
ffmpeg.on('close', () => {
|
|
194
|
+
const matches = stderr.match(/frame=\s*\d+/g);
|
|
195
|
+
if (!matches) {
|
|
196
|
+
reject(new Error(`Could not count video frames for: ${videoPath}`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
resolvePromise(parseInt(matches[matches.length - 1].replace(/\D/g, '')));
|
|
200
|
+
});
|
|
201
|
+
ffmpeg.on('error', reject);
|
|
202
|
+
});
|
|
203
|
+
|
|
131
204
|
export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
|
|
132
205
|
new Promise((resolvePromise, reject) => {
|
|
133
206
|
if (!ffmpegPath) {
|
|
@@ -151,6 +224,7 @@ export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
|
|
|
151
224
|
width: parseInt(dimMatch[1]),
|
|
152
225
|
height: parseInt(dimMatch[2]),
|
|
153
226
|
duration,
|
|
227
|
+
hasAudio: /Stream.*Audio:/.test(stderr),
|
|
154
228
|
});
|
|
155
229
|
});
|
|
156
230
|
});
|
|
@@ -762,6 +836,13 @@ export const buildBeatSyncedSegments = (
|
|
|
762
836
|
return segments;
|
|
763
837
|
};
|
|
764
838
|
|
|
839
|
+
/** Output frames given to the hook video: it plays in full, but never exceeds `totalFrames`. */
|
|
840
|
+
export const computeHookFrameCount = (
|
|
841
|
+
hookDurationSeconds: number,
|
|
842
|
+
fps: number,
|
|
843
|
+
totalFrames: number,
|
|
844
|
+
): number => Math.max(0, Math.min(totalFrames, Math.round(hookDurationSeconds * fps)));
|
|
845
|
+
|
|
765
846
|
export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
|
|
766
847
|
segments
|
|
767
848
|
.slice(1)
|