nodejs-audio-visualizer 5.2.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 +82 -81
- 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 +60 -3
- package/dist/highlight.js +338 -66
- package/dist/highlight.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +150 -80
- 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 +365 -39
- package/src/index.ts +882 -811
- package/src/tests/config.test.ts +19 -0
- package/src/tests/highlight.test.ts +222 -3
- package/src/tests/video.test.ts +80 -1
- package/src/video.ts +81 -0
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)
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
computeHighlightSlice,
|
|
4
|
+
HIGHLIGHT_DURATION_SEC,
|
|
5
|
+
MAX_BEAT_SNAP_SEC,
|
|
6
|
+
buildBeatCandidates,
|
|
7
|
+
snapSegmentToBeats,
|
|
8
|
+
snapDropToBeat,
|
|
9
|
+
} from '../highlight';
|
|
3
10
|
|
|
4
11
|
const dummySpectrums = (n: number) => Array.from({ length: n }, () => [0]);
|
|
5
12
|
|
|
@@ -113,9 +120,10 @@ describe('computeHighlightSlice', function() {
|
|
|
113
120
|
]);
|
|
114
121
|
|
|
115
122
|
expect(result.startFrame).equal(10);
|
|
116
|
-
|
|
123
|
+
// The end moves from frame 460 onto the nearby beat at 500.
|
|
124
|
+
expect(result.highlightFrames).equal(490);
|
|
117
125
|
expect(result.audioSegments).deep.equal([
|
|
118
|
-
{ seekSeconds: 10 / fps, durationSeconds:
|
|
126
|
+
{ seekSeconds: 10 / fps, durationSeconds: 490 / fps },
|
|
119
127
|
]);
|
|
120
128
|
expect(result.runs).have.length(1);
|
|
121
129
|
});
|
|
@@ -175,4 +183,215 @@ describe('computeHighlightSlice', function() {
|
|
|
175
183
|
expect(result.audioSegments).have.length(1);
|
|
176
184
|
expect(result.runs).have.length(1);
|
|
177
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
|
+
|
|
296
|
+
it('snaps highlight start and end onto detected beats', async function() {
|
|
297
|
+
const fps = 30;
|
|
298
|
+
const totalFrames = 1000;
|
|
299
|
+
const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
|
|
300
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
|
|
301
|
+
|
|
302
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
|
|
303
|
+
{ frameIndex: 290, intensity: 1 },
|
|
304
|
+
{ frameIndex: 600, intensity: 1 },
|
|
305
|
+
{ frameIndex: 760, intensity: 1 },
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
expect(result.startFrame).equal(290);
|
|
309
|
+
expect(result.highlightFrames).equal(760 - 290);
|
|
310
|
+
expect(result.audioSegments).deep.equal([
|
|
311
|
+
{ seekSeconds: 290 / fps, durationSeconds: (760 - 290) / fps },
|
|
312
|
+
]);
|
|
313
|
+
expect(result.beatFrameIndices).deep.equal([0, 310]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('snaps to a tempo grid and prefers a whole number of bars', async function() {
|
|
317
|
+
const fps = 30;
|
|
318
|
+
const totalFrames = 2000;
|
|
319
|
+
const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
|
|
320
|
+
const spectrums = spectrumsWithEnergyInRange(totalFrames, 505, 505 + highlightFrames);
|
|
321
|
+
// 120 BPM at 30 fps: a beat every 15 frames, a bar every 60.
|
|
322
|
+
const beatGrid = { periodFrames: 15, phaseFrame: 5 };
|
|
323
|
+
|
|
324
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, beatGrid);
|
|
325
|
+
|
|
326
|
+
expect((result.startFrame - 5) % 15).equal(0);
|
|
327
|
+
expect(result.highlightFrames % 60).equal(0);
|
|
328
|
+
expect(Math.abs(result.startFrame - 505)).lte(MAX_BEAT_SNAP_SEC * fps);
|
|
329
|
+
expect(Math.abs(result.highlightFrames - highlightFrames)).lte(2 * MAX_BEAT_SNAP_SEC * fps);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('keeps several snapped highlights from overlapping', async function() {
|
|
333
|
+
const fps = 30;
|
|
334
|
+
const totalFrames = 3000;
|
|
335
|
+
const spectrums = Array.from({ length: totalFrames }, (_, i) => [1 + Math.sin(i / 50)]);
|
|
336
|
+
const beatGrid = { periodFrames: 14, phaseFrame: 3 };
|
|
337
|
+
|
|
338
|
+
const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 4, beatGrid);
|
|
339
|
+
|
|
340
|
+
for (let i = 1; i < result.runs.length; i++) {
|
|
341
|
+
const prev = result.runs[i - 1];
|
|
342
|
+
expect(result.runs[i].startFrame).gte(prev.startFrame + prev.highlightFrames);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('buildBeatCandidates', function() {
|
|
348
|
+
it('uses onsets when no tempo grid is given', function() {
|
|
349
|
+
expect(buildBeatCandidates(30, 100, [
|
|
350
|
+
{ frameIndex: 40, intensity: 1 },
|
|
351
|
+
{ frameIndex: 10, intensity: 1 },
|
|
352
|
+
{ frameIndex: 10, intensity: 1 },
|
|
353
|
+
])).deep.equal([10, 40]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('uses tracked beats, then the grid nudged onto nearby onsets', function() {
|
|
357
|
+
const frames = buildBeatCandidates(10, 60, [{ frameIndex: 41, intensity: 1 }], {
|
|
358
|
+
periodFrames: 10,
|
|
359
|
+
phaseFrame: 0,
|
|
360
|
+
beatsSec: [0.1, 1.1, 2.1],
|
|
361
|
+
});
|
|
362
|
+
expect(frames).deep.equal([1, 11, 21, 30, 41, 50, 60]);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('snapSegmentToBeats', function() {
|
|
367
|
+
it('leaves a segment unchanged when no beat is close enough', function() {
|
|
368
|
+
const seg = { startFrame: 100, highlightFrames: 200 };
|
|
369
|
+
expect(snapSegmentToBeats(seg, 1000, [10, 900], 20)).deep.equal(seg);
|
|
370
|
+
});
|
|
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
|
+
});
|
|
178
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)
|