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/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 (backgroundVideoPaths.length > 1) {
360
+ if (mainFramesCount <= 0) {
361
+ segments = [];
362
+ } else if (backgroundVideoPaths.length > 1) {
324
363
  segments = autoEditVideo
325
364
  ? buildShuffledVideoSegments(
326
- beatFrameIndices,
327
- framesCount,
365
+ mainBeatFrameIndices,
366
+ mainFramesCount,
328
367
  backgroundVideoPaths.length,
329
368
  fps,
330
369
  {
331
- beatIntensities,
332
- ...(tempo ? { tempo } : {}),
370
+ beatIntensities: mainBeatIntensities,
371
+ ...(mainTempo ? { tempo: mainTempo } : {}),
333
372
  },
334
373
  )
335
374
  : buildSequentialVideoSegments(
336
- framesCount,
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
- beatFrameIndices,
347
- framesCount,
385
+ mainBeatFrameIndices,
386
+ mainFramesCount,
348
387
  sceneChanges,
349
388
  primaryVideoInfo.duration,
350
389
  fps,
351
390
  {
352
- beatIntensities,
353
- ...(autoEditVideo && tempo ? { tempo } : {}),
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: ${backgroundVideoPaths.join(', ')}`);
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 tempo = autoEditVideo && trackTempo
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
  });
@@ -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
+ });
@@ -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)